内存和对象内存池技术在网游开发中的注意点和应用
网络游戏服务器开发技术
-------如何正确高效的使用内存和对象内存池?
大家都知道,游戏服务器在网络游戏开发中所占的比重。而评论游戏服务器的好坏标准,除了实现游戏的逻辑功能外,最重要的也就是稳定和高效。一个不稳定的服务器对于一款网络游戏的打击是沉重,一个不高效的服务器对于玩家的感觉也是非常明显的。
在这一章节中,我将要向大家介绍游戏服务器高效开发的一个方面,如何正确高效的使用内存?而关于其他高效开发的技术或者架构设计,我也将陆续向大家介绍。其中有欠妥之处,也希望读者告诉我,我们大家一起成长。
先简单说下内存和内存使用的通俗概念,内存也就是一块虚拟地址空间。而在C++程序开发过程中,我们可以直接读/写这个地址空间,也就是内存使用。
接下来我们来了解下内存的分配方式。通常情况下,内存的分配方式有3种方式:
1) 从静态存储区域分配。内存在程序编译的时候就已经分配好,这块内
存在程序的整个运行期间都存在。例如全局变量,static变量。
2) 在栈上创建。在执行函数时,函数内局部变量的存储单元都可以在栈
上创建,函数执行结束时这些存储单元自动被释放。栈内存分配运算内置于处理器的指令集中,效率很高,但是分配的内存容量有限。
3) 从堆上分配,亦称动态内存分配。程序在运行的时候用malloc或new
申请任意多少的内存,程序员自己负责在何时用free或delete释放内存。动态内存的生存期由我们决定,使用非常灵活,但问题也最多。
使用点评A:
在游戏服务器开发过程中,如果我们要在函数中需要一块不大于4M的内存,建议我们使用第2)种分配方式。因为这种方式不仅可以使应用程序高效(分配快,且不会造成内存碎片),而且会防止内存泄露。(有时我们的程序员会在不经意间忘记掉释放自己通过new或者malloc分配的内存))
对于三种分配方式,也就对应了三种不同的内存生命周期。所谓生命周期也就是从产生到释放的一个过程时间段。
1) 静态分配的内存空间的生命周期期是:整个软件运行期,就是说从软
件运行开始到软件终止退出。只有软件终止运行后,这块内存才会被系统回收 。
2) 栈中分配的内存空间的生命周期是和相应的函数或者内存对象的作
用域有关。
例如函数如下:
void Func(void)
{
{ int i = 10; i++;}
int j = 0;
for(;j<100;j++){ printf("%d/n",j);}
}
对于上面函数中两个局部变量,虽然都是从栈中进行分配,但生命周期
是不一样的,变量i出了花括号{}后被系统回收,所以在函数其他地方将是无意义的。而变量j在函数结束时被系统回收。但他们都有一个共同点:那就是使用者不需要去关心如何进行分配和释放,所有这一切都是被C++所有保证的。
3) 在堆上分配的内存,生命周期是从调用new或者malloc开始,到调
用delete或者free结束。如果不调用用delete或者free。则这块空间必须到软件终止运行后才能被系统回收。
使用点评B:
在我们写程序过程中如果要在堆中分配内存,必须养成一个良好的习惯。那就是我们首先必须建立成对的new/delete和malloc/free。对于其他使用此内存对象进行逻辑处理的代码就直接放在其中间就可以了。
最后我们再来看看,我们通常使用内存比较容易犯的一些错误。其实大家可不要小看这些错误,很多时候这些错误的产生,对于产品使用者的影响是巨大的。
 内存未分配成功,我们就错误的使用了它。
这种情况的发生。就通常和我们没有养成良好的编程习惯或者经验不丰富有关。常用解决办法是,在使用内存之前检查指针是否为NULL。如果指针p是函数的参数,那么在函数的入口处用if(p!=NULL)进行检查。如果我们在函数中使用malloc或new来申请内存,在使用前必须使用if(p!=NULL)判断后,再进行使用。
 内存分配虽然成功,但是尚未初始化就引用它。
犯这种错误主要有两个起因:一是没有初始化的观念;二是误以为内存的缺省初值全为零,导致引用初值错误(例如数组)。
内存的缺省初值究竟是什么并没有统一的标准,尽管有些时候为零值,我们宁可信其无不可信其有。所以无论用何种方式创建数组,都别忘了赋初值,即便是赋零值也不可省略,不要嫌麻烦。
 内存分配成功并且已经初始化,但操作越过了内存的边界。
这种情况的产生经常会发生在数组下标的错误使用和对象指针的错误内存拷贝。
例如:
void Func(void)
{
char chArray[100];
for(int j=1;j<=100;j++){ chArray[j] = 100; }
}
void Func(void)
{
char *p = (char*)malloc(10);
char *pszA = "DFASDFASDFASDFASDFASDFFD";
memcpy(p,pszA,strlen(pszA));
free(p);
}
 忘记了内存释放,造成内存泄露。
对于这种错误的产生原因可就多种多样了,而最常见犯错误的原因,还是大家没有一个良好的编程习惯或者对于指针使用理解不深刻造成。
下面简单列举大家可能犯错误的几种常见情况:
错误1:
void AllocateMemory(char *pStr, int num)
{
pStr = new char[num];
}
错误2:
void Func(void)
{
CObj *pOBJ = new CObj[100];
if(pStr!=NULL)
{
.........
}
delete pOBJ;
}
使用点评C:
错误1没有很好的理解函数对于参数的处理是为每一个参数设置一个副本,在函数中语句操纵的其实是参数的副本,而本身并没有被改变。
错误2没有将[]配对使用,造成99个CObj对象的析构函数没有被调用,对象本身内存泄露,且程序会报告异常。
 释放了内存却继续使用它。
有几种情况:
 程序中的对象调用关系过于复杂,实在难以搞清楚某个对象究竟是否已经释放了内存,此时应该重新设计程序结构,理清楚对象直接的关系,解决程序混乱的问题。
 函数的return语句写错了,注意不要返回指向“栈内存”的“指针”或者“引用”,因为该内存在函数体结束时被自动销毁。
 使用free或delete释放了内存后,没有将指针设置为NULL。导致产生“野指针”。
 错误的使用栈内存,造成栈溢出。
如果我们在函数中需要一定大小的内存,从栈中分配要比new或者malloc堆内存分
配方式要快很多,所以我们很多朋友就肆无忌惮的使用栈内存。从而造成栈溢出。
“An unhandled exception of type 'System.StackOverflowException' occurred in Temp.exe“
这种情况的产生,多数是大家没有很好的了解内存对象生命周期而造成的。
例如:
void Func(void)
{
for(int i=0;i<10;i++){ char szBuf[1024*1024]; .....}
}
使用点评D:
1.使用指针之前,首先必须检查指针指向内存的有效性。防止使用NULL指针,造成程序崩溃。
2.分配内存成功后,最好要对于内存赋初值。防止将未被初始化的内存作为右值使用,造成程序的错误。
3.避免数组下标或者指针的地址越界,特别要当心发生“多1”、“少1”和内存拷贝的操作。
4.动态内存的申请与释放必须配对,防止内存泄漏。同时不能够将new和free或者malloc和delete进行错误配对。因为这样产生的问题将是难以预料的。
5.用free或delete释放了内存之后,立即将指针设置为NULL,杜绝产生“野指针”。
6.合理的使用栈内存,并且需要关心局部内存对象的生命周期.从而无错高效的使用
栈内存.
上面用了如此多的篇幅来向大家讲述内存的分配、释放和使用注意点。只是想加深大家对于内存使用的理解。下面我们就来继续讨论我们在游戏服务器中如何正确高效使用内存和建立内存管理池。
首先,我先简单的叙述下一般游戏服务器在资源使用方面,都需要做些什么?以下简单归纳为如下几种资源使用情况:
a) 启动Listen端口侦听的Client连接请求。在Client连接成功后,分配一定的
对象资源和此连接对应。
b) 接受Client的网络连接断开请求,释放与此连接所对应的所有资源。
c) 接受Client网络数据包,跟据协议码处理此数据包。处理并且释放协议包资源。
d) 接受DB访问,提出SQL请求、获取记录集、处理记录集,然后释放记录集资源等。
e) 地图Monster对象的产生和销毁.NPC对象产生和销毁等等。
………………
细心的我们,观察以上几点。我们不难发现,在游戏服务器开发过程中,将涉及到大量的内存资源分配和释放。
而对于MMO或者其他休闲网络游戏产品所涉及的各种服务器:longinserver、gamegate、dbserver、gameserver等,由于各自要处理的逻辑和担任角色不一样,其设计复杂度和代码量也不尽相同,这其中以gameserver为其中之最。代码量通常会在几万行到几十万行不等,而其设计复杂度更是根据不同游戏而定。
以上说的只是他们的开发差异,而所有服务器程序必须具备的共同点是:稳定和高性能。同时我们不仅要求单个服务器的稳定和高性能,而是要求全局服务器组的稳定和高性能。因此,这就要求我们服务器程序员必须具体比较强的代码控制能力和丰富的开发经验。而这其中一个比较重要环节就是内存的合理使用。
如果我们使用传统的内存分配方式来进行程序设计,情况将会如下所示:
使用的时候向系统申请,使用后就归还给系统。也正是我们所说的new/delete和malloc/free这种方式。
服务器应用程序开启一段时间之后,我们的系统使用中内存和空闲内存将会呈现如下方式分布:
这其中将会出现许多内存碎片,由于内存碎片的大量存在,我们服务器性能也会随之降低。并且我们还必须承担由于内存使用不当造成系统不稳定或者内存大量泄露的风险。
既然传统的内存编程方式在服务器程序开发和应用过程中有这样的一些潜在弊端。那么我们使用方式来改进我们的服务器应用程序性能和增强系统稳定性呢?
大家不妨尝试使用,我接下来大家要的内容: 对象内存池技术.
大家一定要说了,内存池技术前面为什么要加上“对象”呢。其实就是想和我们平常所说的内存池技术概念进行区别,在这里向大家讲述的是狭义的内存池技术,也就是应用层面的内存池技术。
提示:
在VC6.0以上开发环境中,我们是基于OOP进行游戏服务器开发。在OOP(面向对象编程)编程过程中我们习惯的将我们应用程序中遇到的所有一切进行抽象,成为一个概念对象。
例如:gameserver 中的玩家,我们抽象为class CPlayer,怪物,抽象为class CMonster,网络协议包消息,抽象为class CNetEvent等等。
所以可以简单的说,我们的游戏服务器编程也就是对象编程。而具体这些基础知识和抽象技巧,这里也不多累赘了。
在这里,还是先介绍下我们这边对象内存池技术的基本概念和设计过程:
所谓的对象内存池技术设计过程如下:
首先为某种对象预先生成若干个空闲对象,并且使用对象管理类进行管理。应用程序在需要使用此对象时,即向管理对象申请空闲对象.管理对象即检视对象内存池,如果发现存在未使用空闲对象,即分配给申请者。如果发现已无空闲对象,可自行扩充对象内存池,并且满足申请对象的需求,也可以直接返回NULL,表明对象申请失败。在程序获取对象并且使用后,想释放此对象资源时。继续想管理对象提出申请释放对象,管理对象接受到释放对象后将其再次放入对象池,成为可使用对象。
看了上面的介绍后,接下来以伪代码的方式来更加清晰的展现对象获取和释放的过程。
申请对象
OBJ* ApplyObj(void)
{
if 存在空闲对象
{
OBJ *pIdleObj = NULL;
pIdleObj = GetIdleObj(); //获取空闲对象
return pIdleObj;
}
//不存在空闲对象,处理方式如下
方式1:
ExtendObjectPool(); //扩充对象池
OBJ *pIdleObj = NULL;
pIdleObj = GetIdleObj(); //获取空闲对象
return pIdleObj;
方法2:
return NULL;
}
释放对象
void ReleaseObj(OBJ* pObj)
{
if(pObj!=NULL)
{
AddToObjectPool(pObj); //对象再次加入到对象池
}
}
有了上面的这些说明,我想大家对于对象内存池技术应该都有了一个大概了解吧!(其实没有什么高深的技术,只是一些简单的应用,大家用一个平常心来看待就可以了!)接下介绍具体来实现这个对象内存池,我们需要做些什么?
在实现对象内存池之前,先提出几个我们需要达到的目标:
 对象内存池管理对象具有广泛的通用性,也就是说能够满足应用程序生成各个不同的对象池,例如:Player对象池、Monster对象池、NPC对象池。
 产生对象的速度一定要快于直接使用new/delete或者malloc/free方式很多倍。
 对象池容量具有可扩展性和纠错能力。也就是说在无空闲对象时,管理对象类能够自动生成一批新的空闲对象供上层使用,同时能够正确指出目前所使用对象是否为合法对象池对象。
 对象的申请和释放,必须具备多线程安全性。也就是说在服务器程序通过各个不同线程同时访问对象池管理时,能够保证合法获取和释放池对象。
基于上面这些问题条件,这里先提供几种简单易行的解决方案来供大家参考。其他解决方案还有很多,我也就不一一列举了!靠大家独立思考和发挥了。:)
解决方案1:(单链表实现)
 第一步,分配模板对象数组,并且用指针保存。
 第二步,建立一单链表管理类,将已经分配成功的数组对象,分配到蛋链表中,同时设置表头和表尾指针。
 第三步,从对象池中申请对象,首先检测链表中是否存在可使用空闲对象。如果没有可返回NULL,也可以先锁定扩充链表(保存扩展对象数组指针)。然后返回可使用对象给用户。
 第四步,释放对象池对象时,首先将表尾指针指向被释放对象,接下来被回收对象为此链表表尾。完成释放过程。
 最后,内存释放,delete数组指针。
图例演示如下:
解决方案2:(双链表实现)
 为了能够使我们建立的对象池能够在应用程序中通用,我们考虑使用模板template<class OBJ>来生成我们的class CObjectPool.
 为了能够快速获取对象,我们采用双向链表的方式来建立我们的对象池。对象获取从当前链表头开始进行,对象释放直接加到链表尾。操作过程中需要使用一附加指针对象表明当前可使用对象位置。若此指针为NULL,表明已无可使用空闲对象。
 为了生成一个一定容量的对象池,我们可以通过模板的方式也可以通过初始化Init方式来生成初始对象池。在申请过程中无空闲对象,需要向系统重新一定数目对象,并且按照顺序加到链表尾。实现对象池的可扩充性。
 为了保证多线程安全,我们在对象申请和释放过程中加入Lock进行锁定,保证每次只有一个线程操作对象池。
以上就为此对象池实现的解决方案,在了解到实现过程的前提下,具体实现代码这里也就不累赘了。大家可以发挥实现,如果实现过程中出现问题可以直接和我联系。
重申:
游戏服务器编程不是一个多高深的程序设计课题,设计和开发高质量的网络游戏服务器程序也不是那么的可怕和难以攀登。要的是我们在开发过程中能够多吸收前人所积累的经验,杜绝将前人错误进行重演。同时要的是我们扎实的C++基础功底、认真严谨的开发态度和对于游戏开发事业的120%热情。我们决不要做被程序开发左右的人,要做左右程序开发的人(汗中….)。另外提醒游戏服务器开发,力求稳定实用,个人不建议在代码中使用过多的技巧,同时STL部分建议适量使用。