网络游戏服务端的多线程模型

     上周四公司有一个服务端程序的交流,中间讨论到如何有效利用现在服务器的多核优势,提高单服的负载。
     
     稍有经验的网络游戏服务端开发人员,首先会想说把文件读写等操作慢速设备的操作独立出来,变成单独的线程,那么我们可能会把网络IO,以及文件LOG独立出来,用单独的线程处理。
 
     如果一个游戏服务器,仅开了几个IO线程,比如说开两个线程,一个做网络IO,一个做文件LOG,再加一个逻辑线程的话,那么这个进程的负载应该会受限于单个逻辑线程的运算量。
 
     但只是这样的话是远远不够的。现在我们使用的后端游戏服务器,动辄都是4核,8核的线程,稍好一点的可能都会有16个以上的CPU核心。
 
     上面的做法,至多只能利用到一台机器的三个核心,如果说我们的产品是一些SNS 游戏或是casual game之类的大世界产品,那我们还可以说在同一台机器上多开几个相同的游戏进程,利用并行的方法充分利用CPU资源,那如果说我们做的是MMO ARPG之类的产品呢?
 
     对MMO游戏而言,在同一个世界(一个区或是一条线),策划设计的人数上限应该是整个世界的所有场景和副本,能够负载的人数之和。理论上如果我们一直扩展新的场景,那么应该是可以支持越来越多的玩家。
 
     但是对程序而言,一个世界的负载上限应该是会受限于游戏逻辑线程的处理极限,我的经验是,不管是C++与脚本的混合编程,还是说单独用C/C++编码,上限都不太可能突破到三千人以上。这个数字,可能对现在的很多游戏而言,都不是一个理想的值。
 
     那么我们应该怎么再做改进呢?会上有同事提出,我们可以把逻辑再做拆分,把整个逻辑中比较耗计算的部分,单独拆成一个线程来处理。比如说我把一些怪物AI,或是AOI之类的操作,单独扩成一个线程来处理。
 
     这里可以参考云风最近的博客:http://blog.codingnow.com/2012/09/the_design_of_skynet.html#more
 
     理论上这确实可以这么做,但是我觉得这么做是不可取的。
     
     主要是因为,当我们把逻辑做这种拆分的话,会需要做非常多的数据共享的处理。那只有两种办法,要么加各种锁,要么做各种线程间的通信,我觉得不管哪一种,都会大大加重逻辑程序员的负担。
 
     这样等于对写逻辑的程序员,提出了更高的要求。再熟练的程序员,都不可能在这种情况下,写出没有BUG的代码。这等于是把整个游戏逻辑,置于一个非常容易出BUG的基础之上。
 
     再加上现在像我们这种页游公司,产品开发的周期越来越短,新手又多,想象一下,就觉得这对开发者来说是一个恶梦。
 
     我觉得把逻辑计算拆分到多个线程是很有必要的,但是最好不要按逻辑功能做这种拆分,而是应该把游戏逻辑,按场景,或是休闲游戏的房单,或是SNS的SESSION,这种可以相互独立的逻辑单位,再把这些逻辑单位拆分到各个线程中。
 
     以讨论最多的MMO ARPG为例,我们可以把单个的场景,理解成一个独立的逻辑单元,事实上我们大部分的逻辑,都是在独立场景中完成的,像AOI,战斗计算,怪物AI,寻路等等,跨场景的处理非常少,只有像组队,或是跨场景聊天等少数几个消息,当我们把不同的场景处理,放在不同的线程中时,这样的逻辑,可以单独拿一个线程来处理,做一个线程间的消息处理即可。这个部分可以用ZMQ等成熟的第三方库,也可以自己实现,对稍有C++开发经验的同学,都不是太复杂。
 
     最后整个进程会变成这样:
 
     

 
     这样的设计有两个好处,一是整个进程的负载是可扩展的,而且扩展起来很容易,当新的场景增加时,我们可以简单改一个配置,配到新的场景。当场景不变,当游戏逻辑内容更多时,我们也可以把原来分在两个线程中的场景,拆分到更多线程中来动态扩展。
     另外一个就是,这种设计,让线程这间的交互变得非常少,我们可以把一个场景,理解成一个单独的逻辑单元,在这个逻辑单元内,所有的逻辑都不需要考虑多线程的操作,不容易出错,也更容易理解。
 
     要把游戏进程设计成这样,有两个小技巧。
     一是所有的逻辑系统,都不应该是单件,因为单件打乱了把场景做成一个可以自由扩展的单元这个设定,会导致各种加锁和BUG。我们需要实现一个对象管理器,每一个场景一个,对于各个逻辑系统,都应该是每个场景单独一个对象,场景之间是互相独立的。这样逻辑写起来很方便,也不容易在场景间出现逻辑混乱。
     那么,对场景内的某个系统的引用,会变成这样
     
     AiSystem* pkAi = (AiSystem*)GetObjectManager()->GetObject("AiSystem");
 
     更好一点,会变成这样
     AiSystem* pkAi = GetObjectManager()->GetObject<AiSystem*>("AiSystem");
    用这种方法来取用场景绑定的单件。
 
     另一个就是,如果处理新玩家的联接,以及抛送到逻辑线程。我们的做法是,在主线程接受联接,验证过玩家在哪个场景后,直接用线程间通信的消息,把这个玩家对象的指针发送到对应的线程,没有额外的拷贝和开销。
 
     至于说几条逻辑线程,如何与IO线程通迅,我的做法是每个联接开有一个单读单写的Buffer,这个Buffer是无锁的,以保证最快的速度。IO线程读出网络层的数据,写入到这个Buffer。这个Buffer是和玩家对象绑定在一起的,由具体处理这个玩家逻辑的线程来做读数据和处理。由于每个联接有一个Buffer,这个Buffer又是无锁的,这种做法,会比多个逻辑线程争用同一个读数据队列更快,也不容易出错。关于这个部分,之后看能不能单独开贴说明一下。
 
     当前我们的产品《怪物世界》就是采用了这种设计,只是这是一个大世界的产品,单线的负载远没有达到他的设计极限,现在我们在8核 E5606 CPU服务器上,实际跑到过7千多人一台机,我觉得理论峰值应该能过9千。对一个战斗复杂的MMO来说,我觉得应该算不错的了。
posted @ 2012-09-06 17:40  肉松  阅读(2266)  评论(2编辑  收藏  举报