3.1.2 内存使用的稳定与安全
内存的直接访问对于程序员来说是一件非常酷的事情(高效、强大、可定制),但是对于C++程序故障80%的故障是内存问题导致的,99%的宕机问题是内存问题导致的。Java、C#等语言都把内存管理封装了,就是为了降低编程难度,降低故障率。对于游戏服务器内存问题并非那么恐怖和无法避免。就目前的项目经验,把好几个问题的关,就可以保证服务器不宕。另外服务器的稳定性运行还需要对内存分配和内存拷贝进行控制。
后续逐一对影响服务器稳定运行的内存问题进行说明,并给出解决方案。
1. 内存池接管内存分配与释放(系统长期运行内存碎片导致的性能下降与内存分配可能存在的性能瓶颈)
内存池是游戏服务器稳定运行必须实现的一个底层机制。首先系统的内存分配是一个涉及到内部内存空间列表维护、内存分片、碎片整理的一个耗时低效过程;而且还存在多线程的竞争问题;如果没有内存池,内存分配在一定情况下会成为服务器的性能瓶颈。其次,随着内存的不断分配和释放,物理内存碎片会越来越多,从而导致系统性能下降;也可能引起内存无法分配;或者内存碎片整理;如果没有内存池,是影响服务器系统稳定运行的一个隐患。
对于服务器运行期的内存分配(所有逻辑功能部分的内存分配),我要求全部使用内存池进行分配。对于STL的使用,必须全部使用接管内存配置器的容器。但是对于STL的接管要注意内存的最大值问题,特别是Vector这种大片分配的容器。后续给出几种常见的内存池设计思路和概要代码。
内存池在设计上因为目的不同可能会有几种不同的设计方式。
第一种是用来作为通用内存池的,主要就是解决上述的两个内存问题。内存块根据游戏中的内存使用情况可以分段管理,而非精确分配。32、64、128、256、512、1024……。对于1K以上的,可以根据实际情况考虑继续2指数增加或者采用定制大小的方式。由于是通用内存池,所以需要考虑到多线程安全的问题,在window下面我们使用SLIST_HEADER进行存储我们的空闲节点链表,分配和回收都是使用原子操作进行,以保证最高效的竞争处理。内存块定义数据结构:
Struct MEMPOOL_BLOCK
{
SLIST_ENTRY m_Entry; // 用来进行SLIST_HEADER存储的对象
Unsigned int m_nIndex; // 所在的内存池链表索引
Unsigned int m_nId; // 上层可以用来记录的特殊Id,可以用来区分同一个大小的内容
Struct
{
Const char* m_pAllocFile; // 分配所在的文件
Int m_nAllocLine; //分配所在的行数
Const char* m_pFreeFile; // 释放所在的文件
Int m_nFreeLine; // 释放所在的行数
}m_DebugInfo;
Unsigned int m_nRef; // 该数据被引用的次数
Unsigned char m_Data[1]; // 实际的可用内存
}
内存池对象声明:
ClassCMemPool
{
Public:
CMemPool(unsigned int nMin, unsignedint nMax); //初始化内存池支持的最小内存大小和最大内存大小。如果分配的比最小的要小,那么给最小内存块;如果比最大的还要大,则分配失败。
Virtual ~CMemPool(); //释放所有分配的内存块
Virtual void* Alloc(unsigned intnSize, unsigned int nId); //在正常模式下面调用,实际的分配函数其实是通过调用DebugAlloc(NULL, 0, nSize, nId)来实现
Virtual void Free(void* pPtr); //正常模式下的释放操作,实际通过调用DebugFree(NULL, 0, pPtr)来实现。
Virtual void* DebugAlloc(const char*pFile, int nLine, unsigned int nSize, unsigned int nId); //实际的内存分配函数
Virtual void DebugFree(const char*pFile, int nLine, void* pPtr); //实际的内存释放函数
Private:
Usngined int m_nMinBits, m_nMaxBits; //记录内存对应的最大,最小比特数
Std::vector<SLIST_HEADER>m_arMemList; //实际的空闲内存池列表队列
Unsigned int m_InfoCount[256]; //记录各个内存块的实际分配次数
}
Void*CMemPool::DebugAlloc(const char* pFile, int nLine, unsigned int nSize, unsignedint nId)
{
unsigned int bits;
MEMPOOL_BLOCK* block;
bits = get_bits(nSize);
If(bits > m_nMaxBits) return NULL;
If(bits<m_nMinBits) bits =m_nMinBits;
block=(MEMPOOL_BLOCK*)interlockedPopEntrySlist(&m_arMemList[bits–m_nMinBits];
If(block == NULL)
{
block = (MEMPOOL_BLOCK*)malloc(sizeof(MEMPOOL_BLOCK)+(unsignedint)(1<<bits);
If(block == NULL) return NULL;
block-> m_pFreeFile = NULL;
block->m_nLine = 0;
block->m_nIndex = bits – m_nMinBits;
block->m_nRef = 0;
}
block->m_nId= nId;
block->m_pAllockFile= pFile;
block->m_nLine= nLine;
InterlockedIncrement(&block->m_nRef));
InterlockedIncrement(&m_InfoCount)[bits-m_nMinBits];
}
对于内存泄漏或者内存使用过高的问题排查,可以通过查看dump里面的m_infoCount数组得出每个内存分块的使用量,进而确认问题。另外可以在内存池对象里面增加std::map<const char*, int>来记录每个文件的分配大小,进而精确的确认产生内存泄漏或者内存大量使用的位置。通过对于m_nRef的判定和异常处理可以发现野指针问题。
不过这种内存池也存在几个问题。由于只有分配,没有释放;对于存在一次性使用的内存块会导致大量的内存浪费。(比如由于广播导致的1K的内存块大量分配,而后续很难再用到这个内存块,那么这一次分配的大量内存就浪费了。)其次该内存池仅仅支持内存分配,不支持对象的构造和析构。
第二种内存池在第一种的基础上进行了一次对象的封装,并对于对象的内存分配进行只能使用内存池的强制限制,防止开发过程中的失误。
ClassDyMemPoolObjectBase
{
Template<class T, bool IsPOD>
Friend classDyMemPoolObjectAllocatorImpl;
Public:
// 定义类型,后续分配可以进行是否该类子类的验证
Typedef void Allocate_Object_Drived_From_DyMemPoolObjectBase;
Typedef void Free_Object_Drived_From_DyMemPoolObjectBase;
Private:
//仅仅支持 placement,不支持内存分配
Static inline void* operatornew(size_t, void* p)
{
return p;
}
// 仅仅支持不进行内存释放的delete调用
Static inline void operatordelete(void*, void*)
{
}
}
第三种内存池是对于第二种内存池的一种优化。建立专用对象内存池。主要的思路是对于某些生命周期短的大对象,在new和delete过程中的构造和析构会有着一定的性能消耗。这个内存池系统分配内存使用构造函数初始化而内存池空闲内存分配后使用另一个初始化函数进行定制的初始化。