网络游戏服务器设计


[转]网络游戏服务器设计

谈这个话题之前,首先要让大家知道,什么是服务器。在游戏中,服务器所扮演的角色是同步,广播和服务器主动的一些行为,比如说天气,NPC AI之类的,之所以现在的很多网络游戏服务器都需要负担一些游戏逻辑上的运算是因为为了防止客户端的作弊行为。了解到这一点,那么本系列的文章将分为两部分来谈谈网络游戏服务器的设计,一部分是讲如何做好服务器的网络连接,同步,广播以及NPC的设置,另一部分则将着重谈谈哪些逻辑放在服务器比较合适,并且用什么样的结构来安排这些逻辑。

服务器的网络连接

  大多数的网络游戏的服务器都会选择非阻塞select这种结构,为什么呢?因为网络游戏的服务器需要处理的连接非常之多,并且大部分会选择在Linux/Unix下运行,那么为每个用户开一个线程实际上是很不划算的,一方面因为在Linux/Unix下的线程是用进程这么一个概念模拟出来的,比较消耗系统资源,另外除了I/O之外,每个线程基本上没有什么多余的需要并行的任务,而且网络游戏是互交性非常强的,所以线程间的同步会成为很麻烦的问题。由此一来,对于这种含有大量网络连接的单线程服务器,用阻塞显然是不现实的。对于网络连接,需要用一个结构来储存,其中需要包含一个向客户端写消息的缓冲,还需要一个从客户端读消息的缓冲,具体的大小根据具体的消息结构来定了。另外对于同步,需要一些时间校对的值,还需要一些各种不同的值来记录当前状态,下面给出一个初步的连接的结构:

网络游戏typedef connection_s {

    user_t *ob; /* 指向处理服务器端逻辑的结构 */

    int fd; /* socket连接 */

    struct sockaddr_in addr; /* 连接的地址信息 */

    char text[MAX_TEXT]; /* 接收的消息缓冲 */

    int text_end; /* 接收消息缓冲的尾指针 */

    int text_start; /* 接收消息缓冲的头指针 */

    int last_time; /* 上一条消息是什么时候接收到的 */

    struct timeval latency; /* 客户端本地时间和服务器本地时间的差值 */

    struct timeval last_confirm_time; /* 上一次验证的时间 */

    short is_confirmed; /* 该连接是否通过验证过 */

    int ping_num; /* 该客户端到服务器端的ping值 */

    int ping_ticker; /* 多少个IO周期处理更新一次ping值 */

    int message_length; /* 发送缓冲消息长度 */

    char message_buf[MAX_TEXT]; /* 发送缓冲区 */

    int iflags; /* 该连接的状态 */

} connection_t;

  服务器循环的处理所有连接,是一个死循环过程,每次循环都用select检查是否有新连接到达,然后循环所有连接,看哪个连接可以写或者可以读,就处理该连接的读写。由于所有的处理都是非阻塞的,所以所有的Socket IO都可以用一个线程来完成。

  由于网络传输的关系,每次recv()到的数据可能不止包含一条消息,或者不到一条消息,那么怎么处理呢?所以对于接收消息缓冲用了两个指针,每次接收都从text_start开始读起,因为里面残留的可能是上次接收到的多余的半条消息,然后text_end指向消息缓冲的结尾。这样用两个指针就可以很方便的处理这种情况,另外有一点值得注意的是:解析消息的过程是一个循环的过程,可能一次接收到两条以上的消息在消息缓冲里面,这个时候就应该执行到消息缓冲里面只有一条都不到的消息为止,大体流程如下:

while ( text_end – text_start > 一条完整的消息长度 )

{

    从text_start处开始处理;

    text_start += 该消息长度;

}

memcpy ( text, text + text_start, text_end – text_start );

  对于消息的处理,这里首先就需要知道你的游戏总共有哪些消息,所有的消息都有哪些,才能设计出比较合理的消息头。一般来说,消息大概可分为主角消息,场景消息,同步消息和界面消息四个部分。其中主角消息包括客户端所控制的角色的所有动作,包括走路,跑步,战斗之类的。场景消息包括天气变化,一定的时间在场景里出现一些东西等等之类的,这类消息的特点是所有消息的发起者都是服务器,广播对象则是场景里的所有玩家。而同步消息则是针对发起对象是某个玩家,经过服务器广播给所有看得见他的玩家,该消息也是包括所有的动作,和主角消息不同的是该种消息是服务器广播给客户端的,而主角消息一般是客户端主动发给服务器的。最后是界面消息,界面消息包括是服务器发给客户端的聊天消息和各种属性及状态信息。

  下面来谈谈消息的组成。一般来说,一个消息由消息头和消息体两部分组成,其中消息头的长度是不变的,而消息体的长度是可变的,在消息体中需要保存消息体的长度。由于要给每条消息一个很明显的区分,所以需要定义一个消息头特有的标志,然后需要消息的类型以及消息ID。消息头大体结构如下:

type struct message_s {

    unsigned short message_sign;

    unsigned char message_type;

    unsigned short message_id

    unsigned char message_len

}message_t;


服务器的广播

  服务器的广播的重点就在于如何计算出广播的对象。很显然,在一张很大的地图里面,某个玩家在最东边的一个动作,一个在最西边的玩家是应该看不到的,那么怎么来计算广播的对象呢?最简单的办法,就是把地图分块,分成大小合适的小块,然后每次只象周围几个小块的玩家进行广播。那么究竟切到多大比较合适呢?一般来说,切得块大了,内存的消耗会增大,切得块小了,CPU的消耗会增大(原因会在后面提到)。个人觉得切成一屏左右的小块比较合适,每次广播广播周围九个小块的玩家,由于广播的操作非常频繁,那么遍利周围九块的操作就会变得相当的频繁,所以如果块分得小了,那么遍利的范围就会扩大,CPU的资源会很快的被吃完。

  切好块以后,怎么让玩家在各个块之间走来走去呢?让我们来想想在切换一次块的时候要做哪些工作。首先,要算出下个块的周围九块的玩家有哪些是现在当前块没有的,把自己的信息广播给那些玩家,同时也要算出下个块周围九块里面有哪些物件是现在没有的,把那些物件的信息广播给自己,然后把下个块的周围九快里没有的,而现在的块周围九块里面有的物件的消失信息广播给自己,同时也把自己消失的消息广播给那些物件。这个操作不仅烦琐而且会吃掉不少CPU资源,那么有什么办法可以很快的算出这些物件呢?一个个做比较?显然看起来就不是个好办法,这里可以参照二维矩阵碰撞检测的一些思路,以自己周围九块为一个矩阵,目标块周围九块为另一个矩阵,检测这两个矩阵是否碰撞,如果两个矩阵相交,那么没相交的那些块怎么算。这里可以把相交的块的坐标转换成内部坐标,然后再进行运算。

  对于广播还有另外一种解决方法,实施起来不如切块来的简单,这种方法需要客户端来协助进行运算。首先在服务器端的连接结构里面需要增加一个广播对象的队列,该队列在客户端登陆服务器的时候由服务器传给客户端,然后客户端自己来维护这个队列,当有人走出客户端视野的时候,由客户端主动要求服务器给那个物件发送消失的消息。而对于有人总进视野的情况,则比较麻烦了。

  首先需要客户端在每次给服务器发送update position的消息的时候,服务器都给该连接算出一个视野范围,然后在需要广播的时候,循环整张地图上的玩家,找到坐标在其视野范围内的玩家。使用这种方法的好处在于不存在转换块的时候需要一次性广播大量的消息,缺点就是在计算广播对象的时候需要遍历整个地图上的玩家,如果当一个地图上的玩家多得比较离谱的时候,该操作就会比较的慢。

服务器的同步

  同步在网络游戏中是非常重要的,它保证了每个玩家在屏幕上看到的东西大体是一样的。其实呢,解决同步问题的最简单的方法就是把每个玩家的动作都向其他玩家广播一遍,这里其实就存在两个问题:1,向哪些玩家广播,广播哪些消息。2,如果网络延迟怎么办。事实上呢,第一个问题是个非常简单的问题,不过之所以我提出这个问题来,是提醒大家在设计自己的消息结构的时候,需要把这个因素考虑进去。而对于第二个问题,则是一个挺麻烦的问题,大家可以来看这么个例子:

  比如有一个玩家A向服务器发了条指令,说我现在在P1点,要去P2点。指令发出的时间是T0,服务器收到指令的时间是T1,然后向周围的玩家广播这条消息,消息的内容是“玩家A从P1到P2”有一个在A附近的玩家B,收到服务器的这则广播的消息的时间是T2,然后开始在客户端上画图,A从P1到P2点。这个时候就存在一个不同步的问题,玩家A和玩家B的屏幕上显示的画面相差了T2-T1的时间。这个时候怎么办呢?

  有个解决方案,我给它取名叫 预测拉扯,虽然有些怪异了点,不过基本上大家也能从字面上来理解它的意思。要解决这个问题,首先要定义一个值叫:预测误差。然后需要在服务器端每个玩家连接的类里面加一项属性,叫latency,然后在玩家登陆的时候,对客户端的时间和服务器的时间进行比较,得出来的差值保存在latency里面。还是上面的那个例子,服务器广播消息的时候,就根据要广播对象的latency,计算出一个客户端的CurrentTime,然后在消息头里面包含这个CurrentTime,然后再进行广播。并且同时在玩家A的客户端本地建立一个队列,保存该条消息,只到获得服务器验证就从未被验证的消息队列里面将该消息删除,如果验证失败,则会被拉扯回P1点。然后当玩家B收到了服务器发过来的消息“玩家A从P1到P2”这个时候就检查消息里面服务器发出的时间和本地时间做比较,如果大于定义的预测误差,就算出在T2这个时间,玩家A的屏幕上走到的地点P3,然后把玩家B屏幕上的玩家A直接拉扯到P3,再继续走下去,这样就能保证同步。更进一步,为了保证客户端运行起来更加smooth,我并不推荐直接把玩家拉扯过去,而是算出P3偏后的一点P4,然后用(P4-P1)/T(P4-P3)来算出一个很快的速度S,然后让玩家A用速度S快速移动到P4,这样的处理方法是比较合理的,这种解决方案的原形在国际上被称为(Full plesiochronous),当然,该原形被我篡改了很多来适应网络游戏的同步,所以而变成所谓的:预测拉扯。

  另外一个解决方案,我给它取名叫 验证同步,听名字也知道,大体的意思就是每条指令在经过服务器验证通过了以后再执行动作。具体的思路如下:首先也需要在每个玩家连接类型里面定义一个latency,然后在客户端响应玩家鼠标行走的同时,客户端并不会先行走动,而是发一条走路的指令给服务器,然后等待服务器的验证。服务器接受到这条消息以后,进行逻辑层的验证,然后计算出需要广播的范围,包括玩家A在内,根据各个客户端不同的latency生成不同的消息头,开始广播,这个时候这个玩家的走路信息就是完全同步的了。这个方法的优点是能保证各个客户端之间绝对的同步,缺点是当网络延迟比较大的时候,玩家的客户端的行为会变得比较不流畅,给玩家带来很不爽的感觉。该种解决方案的原形在国际上被称为(Hierarchical master-slave synchronization),80年代以后被广泛应用于网络的各个领域。

  最后一种解决方案是一种理想化的解决方案,在国际上被称为Mutual synchronization,是一种对未来网络的前景的良好预测出来的解决方案。这里之所以要提这个方案,并不是说我们已经完全的实现了这种方案,而只是在网络游戏领域的某些方面应用到这种方案的某些思想。我对该种方案取名为:半服务器同步。大体的设计思路如下:

  首先客户端需要在登陆世界的时候建立很多张广播列表,这些列表在客户端后台和服务器要进行不及时同步,之所以要建立多张列表,是因为要广播的类型是不止一种的,比如说有local message,有remote message,还有global message 等等,这些列表都需要在客户端登陆的时候根据服务器发过来的消息建立好。在建立列表的同时,还需要获得每个列表中广播对象的latency,并且要维护一张完整的用户状态列表在后台,也是不及时的和服务器进行同步,根据本地的用户状态表,可以做到一部分决策由客户端自己来决定,当客户端发送这部分决策的时候,则直接将最终决策发送到各个广播列表里面的客户端,并对其时间进行校对,保证每个客户端在收到的消息的时间是和根据本地时间进行校对过的。那么再采用预测拉扯中提到过的计算提前量,提高速度行走过去的方法,将会使同步变得非常的smooth。该方案的优点是不通过服务器,客户端自己之间进行同步,大大的降低了由于网络延迟而带来的误差,并且由于大部分决策都可以由客户端来做,也大大的降低了服务器的资源。由此带来的弊端就是由于消息和决策权都放在客户端本地,所以给外挂提供了很大的可乘之机。

  下面我想来谈谈关于服务器上NPC的设计以及NPC智能等一些方面涉及到的问题。首先,我们需要知道什么是NPC,NPC需要做什么。NPC的全称是(Non-Player Character),很显然,他是一个character,但不是玩家,那么从这点上可以知道,NPC的某些行为是和玩家类似的,他可以行走,可以战斗,可以呼吸(这点将在后面的NPC智能里面提到),另外一点和玩家物件不同的是,NPC可以复生(即NPC被打死以后在一定时间内可以重新出来)。其实还有最重要的一点,就是玩家物件的所有决策都是玩家做出来的,而NPC的决策则是由计算机做出来的,所以在对NPC做何种决策的时候,需要所谓的NPC智能来进行决策。

  下面我将分两个部分来谈谈NPC,首先是NPC智能,其次是服务器如何对NPC进行组织。之所以要先谈NPC智能是因为只有当我们了解清楚我们需要NPC做什么之后,才好开始设计服务器来对NPC进行组织。


NPC智能

  NPC智能分为两种,一种是被动触发的事件,一种是主动触发的事件。对于被动触发的事件,处理起来相对来说简单一些,可以由事件本身来呼叫NPC身上的函数,比如说NPC的死亡,实际上是在NPC的HP小于一定值的时候,来主动呼叫NPC身上的OnDie() 函数,这种由事件来触发NPC行为的NPC智能,我称为被动触发。这种类型的触发往往分为两种:

一种是由别的物件导致的NPC的属性变化,然后属性变化的同时会导致NPC产生一些行为。由此一来,NPC物件里面至少包含以下几种函数:

class NPC {

public:

    // 是谁在什么地方导致了我哪项属性改变了多少。

    OnChangeAttribute(object_t *who, int which, int how, int where);

Private:

    OnDie();

    OnEscape();

    OnFollow();

    OnSleep();

    // 一系列的事件。

}

  这是一个基本的NPC的结构,这种被动的触发NPC的事件,我称它为NPC的反射。但是,这样的结构只能让NPC被动的接收一些信息来做出决策,这样的NPC是愚蠢的。那么,怎么样让一个NPC能够主动的做出一些决策呢?这里有一种方法:呼吸。那么怎么样让NPC有呼吸呢?

  一种很简单的方法,用一个计时器,定时的触发所有NPC的呼吸,这样就可以让一个NPC有呼吸起来。这样的话会有一个问题,当NPC太多的时候,上一次NPC的呼吸还没有呼吸完,下一次呼吸又来了,那么怎么解决这个问题呢。这里有一种方法,让NPC异步的进行呼吸,即每个NPC的呼吸周期是根据NPC出生的时间来定的,这个时候计时器需要做的就是隔一段时间检查一下,哪些NPC到时间该呼吸了,就来触发这些NPC的呼吸。

  上面提到的是系统如何来触发NPC的呼吸,那么NPC本身的呼吸频率该如何设定呢?这个就好象现实中的人一样,睡觉的时候和进行激烈运动的时候,呼吸频率是不一样的。同样,NPC在战斗的时候,和平常的时候,呼吸频率也不一样。那么就需要一个Breath_Ticker来设置NPC当前的呼吸频率。

  那么在NPC的呼吸事件里面,我们怎么样来设置NPC的智能呢?大体可以概括为检查环境和做出决策两个部分。首先,需要对当前环境进行数字上的统计,比如说是否在战斗中,战斗有几个敌人,自己的HP还剩多少,以及附近有没有敌人等等之类的统计。统计出来的数据传入本身的决策模块,决策模块则根据NPC自身的性格取向来做出一些决策,比如说野蛮型的NPC会在HP比较少的时候仍然猛扑猛打,又比如说智慧型的NPC则会在HP比较少的时候选择逃跑。等等之类的。

  至此,一个可以呼吸,反射的NPC的结构已经基本构成了,那么接下来我们就来谈谈系统如何组织让一个NPC出现在世界里面。


NPC的组织

  这里有两种方案可供选择,其一:NPC的位置信息保存在场景里面,载入场景的时候载入NPC。其二,NPC的位置信息保存在NPC身上,有专门的事件让所有的NPC登陆场景。这两种方法有什么区别呢?又各有什么好坏呢?

  前一种方法好处在于场景载入的时候同时载入了NPC,场景就可以对NPC进行管理,不需要多余的处理,而弊端则在于在刷新的时候是同步刷新的,也就是说一个场景里面的NPC可能会在同一时间内长出来。而对于第二种方法呢,设计起来会稍微麻烦一些,需要一个统一的机制让NPC登陆到场景,还需要一些比较麻烦的设计,但是这种方案可以实现NPC异步的刷新,是目前网络游戏普遍采用的方法,下面我们就来着重谈谈这种方法的实现:

  首先我们要引入一个“灵魂”的概念,即一个NPC在死后,消失的只是他的肉体,他的灵魂仍然在世界中存在着,没有呼吸,在死亡的附近漂浮,等着到时间投胎,投胎的时候把之前的所有属性清零,重新在场景上构建其肉体。那么,我们怎么来设计这样一个结构呢?首先把一个场景里面要出现的NPC制作成图量表,给每个NPC一个独一无二的标识符,在载入场景之后,根据图量表来载入属于该场景的NPC。在NPC的OnDie() 事件里面不直接把该物件destroy 掉,而是关闭NPC的呼吸,然后打开一个重生的计时器,最后把该物件设置为invisable。这样的设计,可以实现NPC的异步刷新,在节省服务器资源的同时也让玩家觉得更加的真实。

(这一章节已经牵扯到一些服务器脚本相关的东西,所以下一章节将谈谈服务器脚本相关的一些设计)

  补充的谈谈启发式搜索(heuristic searching)在NPC智能中的应用。

  其主要思路是在广度优先搜索的同时,将下一层的所有节点经过一个启发函数进行过滤,一定范围内缩小搜索范围。众所周知的寻路A*算法就是典型的启发式搜索的应用,其原理是一开始设计一个Judge(point_t* point)函数,来获得point这个一点的代价,然后每次搜索的时候把下一步可能到达的所有点都经过Judge()函数评价一下,获取两到三个代价比较小的点,继续搜索,那些没被选上的点就不会在继续搜索下去了,这样带来的后果的是可能求出来的不是最优路径,这也是为什么A*算法在寻路的时候会走到障碍物前面再绕过去,而不是预先就走斜线来绕过该障碍物。如果要寻出最优化的路径的话,是不能用A*算法的,而是要用动态规划的方法,其消耗是远大于A*的。

  那么,除了在寻路之外,还有哪些地方可以应用到启发式搜索呢?其实说得大一点,NPC的任何决策都可以用启发式搜索来做,比如说逃跑吧,如果是一个2D的网络游戏,有八个方向,NPC选择哪个方向逃跑呢?就可以设置一个Judge(int direction)来给定每个点的代价,在Judge里面算上该点的敌人的强弱,或者该敌人的敏捷如何等等,最后选择代价最小的地方逃跑。下面,我们就来谈谈对于几种NPC常见的智能的启发式搜索法的设计:

Target select (选择目标):

  首先获得地图上离该NPC附近的敌人列表。设计Judge() 函数,根据敌人的强弱,敌人的远近,算出代价。然后选择代价最小的敌人进行主动攻击。

Escape(逃跑):

  在呼吸事件里面检查自己的HP,如果HP低于某个值的时候,或者如果你是远程兵种,而敌人近身的话,则触发逃跑函数,在逃跑函数里面也是对周围的所有的敌人组织成列表,然后设计Judge() 函数,先选择出对你构成威胁最大的敌人,该Judge() 函数需要判断敌人的速度,战斗力强弱,最后得出一个主要敌人,然后针对该主要敌人进行路径的Judge() 的函数的设计,搜索的范围只可能是和主要敌人相反的方向,然后再根据该几个方向的敌人的强弱来计算代价,做出最后的选择。

Random walk(随机走路):

  这个我并不推荐用A*算法,因为NPC一旦多起来,那么这个对CPU的消耗是很恐怖的,而且NPC大多不需要长距离的寻路,只需要在附近走走即可,那么,就在附近随机的给几个点,然后让NPC走过去,如果碰到障碍物就停下来,这样几乎无任何负担。

Follow Target(追随目标):

  这里有两种方法,一种方法NPC看上去比较愚蠢,一种方法看上去NPC比较聪明,第一种方法就是让NPC跟着目标的路点走即可,几乎没有资源消耗。而后一种则是让NPC在跟随的时候,在呼吸事件里面判断对方的当前位置,然后走直线,碰上障碍物了用A*绕过去,该种设计会消耗一定量的系统资源,所以不推荐NPC大量的追随目标,如果需要大量的NPC追随目标的话,还有一个比较简单的方法:让NPC和目标同步移动,即让他们的速度统一,移动的时候走同样的路点,当然,这种设计只适合NPC所跟随的目标不是追杀的关系,只是跟随着玩家走而已了。

 在这一章节,我想谈谈关于服务器端的脚本的相关设计。因为在上一章节里面,谈NPC智能相关的时候已经接触到一些脚本相关的东东了。还是先来谈谈脚本的作用吧。

  在基于编译的服务器端程序中,是无法在程序的运行过程中构建一些东西的,那么这个时候就需要脚本语言的支持了,由于脚本语言涉及到逻辑判断,所以光提供一些函数接口是没用的,还需要提供一些简单的语法和文法解析的功能。其实说到底,任何的事件都可以看成两个部分:第一是对自身,或者别的物件的数值的改变,另外一个就是将该事件以文字或者图形的方式广播出去。那么,这里牵扯到一个很重要的话题,就是对某一物件进行寻址。恩,谈到这,我想将本章节分为三个部分来谈,首先是服务器如何来管理动态创建出来的物件(服务器内存管理),第二是如何对某一物件进行寻址,第三则是脚本语言的组织和解释。其实之所以到第四章再来谈服务器的内存管理是因为在前几章谈这个的话,大家对其没有一个感性的认识,可能不知道服务器的内存管理究竟有什么用。

4.1、服务器内存管理

  对于服务器内存管理我们将采用内存池的方法,也称为静态内存管理。其概念为在服务器初始化的时候,申请一块非常大的内存,称为内存池(Memory pool),同时也申请一小块内存空间,称为垃圾回收站(Garbage recollecting station)。其大体思路如下:当程序需要申请内存的时候,首先检查垃圾回收站是否为空,如果不为空的话,则从垃圾回收站中找一块可用的内存地址,在内存池中根据地址找到相应的空间,分配给程序用,如果垃圾回收站是空的话,则直接从内存池的当前指针位置申请一块内存;当程序释放空间的时候,给那块内存打上已经释放掉的标记,然后把那块内存的地址放入垃圾回收站。
  下面具体谈谈该方法的详细设计,首先,我们将采用类似于操作系统的段页式系统来管理内存,这样的好处是可以充分的利用内存池,其缺点是管理起来比较麻烦。嗯,下面来具体看看我们怎么样来定义页和段的结构:

  typedef struct m_segment_s
  {
    struct m_segment_s *next; /* 双线链表 + 静态内存可以达到随机访问和顺序访问的目的,
                   真正的想怎么访问,就怎么访问。 */
    struct m_segment_s *pre; int flags;  // 该段的一些标记。
    int start;              // 相对于该页的首地址。
    int size;               // 长度。
    struct m_page_s *my_owner;      // 我是属于哪一页的。
    char *data;              // 内容指针。
  }m_segment_t;

  typedef struct m_page_s
  {
    unsigned int flags;   /* 使用标记,是否完全使用,是否还有空余 */
    int size;        /* 该页的大小,一般都是统一的,最后一页除外 */
    int end;         /* 使用到什么地方了 */
    int my_index;      /* 提供随机访问的索引 */
    m_segment_t *segments;  // 页内段的头指针。
  }m_page_t;

  那么内存池和垃圾回收站怎么构建呢?下面也给出一些构建相关的伪代码:

  static m_page_t *all_pages;
  // total_size是总共要申请的内存数,num_pages是总共打算创建多少个页面。
  void initialize_memory_pool( int total_size, int num_pages )
  {
    int i, page_size, last_size;    // 算出每个页面的大小。
    page_size = total_size / num_pages; // 分配足够的页面。
    all_pages = (m_page_t*) calloc( num_pages, sizeof(m_page_t*) );
    for ( i = 0; i < num_pages; i ++ )
    {
      // 初始化每个页面的段指针。
      all_pages[i].m_segment_t = (m_segment_t*) malloc( page_size );
      // 初始化该页面的标记。
      all_pages[i].flags |= NEVER_USED;
      // 除了最后一个页面,其他的大小都是page_size 大小。
      all_pages[i].size = page_size;
      // 初始化随机访问的索引。
      all_pages[i].my_index = i;
      // 由于没有用过,所以大小都是0
      all_pages[i].end = 0;
    }

    // 设置最后一个页面的大小。
    if ( (last_size = total_size % num_pages) != 0 )
      all_pages[i].size = last_size;
  }

  下面看看垃圾回收站怎么设计:

  int **garbage_station;
  void init_garbage_station( int num_pages, int page_size )
  {
    int i;
    garbage_station = (int**) calloc( num_pages, sizeof( int* ) );
    for ( i = 0; i < num_pages; i ++)
    {
      // 这里用unsigned short的高8位来储存首相对地址,低8位来储存长度。
      garbage_station[i] = (int*) calloc( page_size, sizeof( unsigned short ));
      memset( garbage_station[i], 0, sizeof( garbage_station[i] ));
    }
  }

  也许这样的贴代码会让大家觉得很不明白,嗯,我的代码水平确实不怎么样,那么下面我来用文字方式来叙说一下大体的概念吧。对于段页式内存管理,首先分成N个页面,这个是固定的,而对于每个页面内的段则是动态的,段的大小事先是不知道的,那么我们需要回收的不仅仅是页面的内存,还包括段的内存,那么我们就需要一个二维数组来保存是哪个页面的那块段的地址被释放了。然后对于申请内存的时候,则首先检查需要申请内存的大小,如果不够一个页面大小的话,则在垃圾回收站里面寻找可用的段空间分配,如果找不到,则申请一个新的页面空间。
  这样用内存池的方法来管理整个游戏世界的内存可以有效的减少内存碎片,一定程度的提高游戏运行的稳定性和效率。

4.2、游戏中物件的寻址

  第一个问题,我们为什么要寻址?加入了脚本语言的概念之后,游戏中的一些逻辑物件,比如说NPC,某个ITEM之类的都是由脚本语言在游戏运行的过程中动态生成的,那么我们通过什么样的方法来对这些物件进行索引呢?说得简单一点,就是如何找到他们呢?有个很简单的方法,全部遍历一次。当然,这是个简单而有效的方法,但是效率上的消耗是任何一台服务器都吃不消的,特别是在游戏的规模比较大之后。

  那么,我们怎么来在游戏世界中很快的寻找这些物件呢?我想在谈这个之前,说一下Hash Table这个数据结构,它叫哈希表,也有人叫它散列表,其工作原理是不是顺序访问,也不是随机访问,而是通过一个散列函数对其key进行计算,算出在内存中这个key对应的value的地址,而对其进行访问。好处是不管面对多大的数据,只需要一次计算就能找到其地址,非常的快捷,那么弊端是什么呢?当两个key通过散列函数计算出来的地址是同一个地址的时候,麻烦就来了,会产生碰撞,其的解决方法非常的麻烦,这里就不详细谈其解决方法了,否则估计再写个四,五章也未必谈得清楚,不过如果大家对其感兴趣的话,欢迎讨论。

  嗯,我们将用散列表来对游戏中的物件进行索引,具体怎么做呢?首先,在内存池中申请一块两倍大于游戏中物件总数的内存,为什么是两倍大呢?防止散列表碰撞。然后我们选用物件的名称作为散列表的索引key,然后就可以开始设计散列函数了。下面来看个例子:

  static int T[] =
  {
    1, 87, 49, 12, 176, 178, 102, 166, 121, 193, 6, 84, 249, 230, 44, 163,
    14, 197, 213, 181, 161, 85, 218, 80, 64, 239, 24, 226, 236, 142, 38, 200,
    110, 177, 104, 103, 141, 253, 255, 50, 77, 101, 81, 18, 45, 96, 31, 222,
    25, 107, 190, 70, 86, 237, 240, 34, 72, 242, 20, 214, 244, 227, 149, 235,
    97, 234, 57, 22, 60, 250, 82, 175, 208, 5, 127, 199, 111, 62, 135, 248,
    174, 169, 211, 58, 66, 154, 106, 195, 245, 171, 17, 187, 182, 179, 0, 243,
    132, 56, 148, 75, 128, 133, 158, 100, 130, 126, 91, 13, 153, 246, 216, 219,
    119, 68, 223, 78, 83, 88, 201, 99, 122, 11, 92, 32, 136, 114, 52, 10,
    138, 30, 48, 183, 156, 35, 61, 26, 143, 74, 251, 94, 129, 162, 63, 152,
    170, 7, 115, 167, 241, 206, 3, 150, 55, 59, 151, 220, 90, 53, 23, 131,
    125, 173, 15, 238, 79, 95, 89, 16, 105, 137, 225, 224, 217, 160, 37, 123,
    118, 73, 2, 157, 46, 116, 9, 145, 134, 228, 207, 212, 202, 215, 69, 229,
    27, 188, 67, 124, 168, 252, 42, 4, 29, 108, 21, 247, 19, 205, 39, 203,
    233, 40, 186, 147, 198, 192, 155, 33, 164, 191, 98, 204, 165, 180, 117, 76,
    140, 36, 210, 172, 41, 54, 159, 8, 185, 232, 113, 196, 231, 47, 146, 120,
    51, 65, 28, 144, 254, 221, 93, 189, 194, 139, 112, 43, 71, 109, 184, 209,
  };

  // s是需要进行索引的字符串指针,maxn是字符串可能的最大长度,返回值是相对地址。
  inline int whashstr(char *s, int maxn)
  {
    register unsigned char oh, h;
    register unsigned char *p;
    register int i;

    if (!*s)
      return 0;
    p = (unsigned char *) s;
    oh = T[*p]; h = (*(p++) + 1) & 0xff;
    for (i = maxn - 1; *p && --i >= 0; )
    {
      oh = T[oh ^ *p]; h = T[h ^ *(p++)];
    }
    return (oh << 8) + h;
  }

  具体的算法就不说了,上面的那一大段东西不要问我为什么,这个算法的出处是CACM 33-6中的一个叫Peter K.Pearson的鬼子写的论文中介绍的算法,据说速度非常的快。有了这个散列函数,我们就可以通过它来对世界里面的任意物件进行非常快的寻址了。

4.3、脚本语言解释

  在设计脚本语言之前,我们首先需要明白,我们的脚本语言要实现什么样的功能?否则随心所欲的做下去写出个C的解释器之类的也说不定。我们要实现的功能只是简单的逻辑判断和循环,其他所有的功能都可以由事先提供好的函数来完成。嗯,这样我们就可以列出一张工作量的表单:设计物件在底层的保存结构,提供脚本和底层间的访问接口,设计支持逻辑判断和循环的解释器。

  下面先来谈谈物件在底层的保存结构。具体到每种不同属性的物件,需要采用不同的结构,当然,如果你愿意的话,你可以所有的物件都采同同样的结构,然后在结构里面设计一个散列表来保存各种不同的属性。但这并不是一个好方法,过分的依赖散列表会让你的游戏的逻辑变得繁杂不清。所以,尽量的区分每种不同的物件采用不同的结构来设计。但是有一点值得注意的是,不管是什么结构,有一些东西是统一的,就是我们所说的物件头,那么我们怎么来设计这样一个物件头呢?

  typedef struct object_head_s
  {
    char* name;
    char* prog;
  }object_head_t;

  其中name是在散列表中这个物件的索引号,prog则是脚本解释器需要解释的程序内容。下面我们就以NPC为例来设计一个结构:

  typedef struct npc_s
  {
    object_head_t header;    // 物件头
    int hp;           // NPC的hp值。
    int level;          // NPC的等级。
    struct position_s position; // 当前的位置信息。
    unsigned int personality;  // NPC的个性,一个unsigned int可以保存24种个性。
  }npc_t;

  OK,结构设计完成,那么我们怎么来设计脚本解释器呢?这里有两种法,一种是用虚拟机的模式来解析脚本语言,另外一中则是用类似汇编语言的那种结构来设计,设置一些条件跳转和循环就可以实现逻辑判断和循环了,比如:

  set name, "路人甲";
  CHOOSE: random_choose_personality;  // 随机选择NPC的个性
  compare hp, 100;           // 比较气血,比较出的值可以放在一个固定的变量里面
  ifless LESS;             // hp < 100的话,则返回。
  jump CHOOSE;             // 否则继续选择,只到选到一个hp < 100的。
  LESS: return success;

  这种脚本结构就类似CPU的指令的结构,一条一条指令按照顺序执行,对于脚本程序员(Script. Programmer)也可以培养他们汇编能力的说。

  那么怎么来模仿这种结构呢?我们拿CPU的指令做参照,首先得设置一些寄存器,CPU的寄存器的大小和数量是受硬件影响的,但我们是用内存来模拟寄存器,所以想要多大,就可以有多大。然后提供一些指令,包括四则运算,寻址,判断,循环等等。接下来针对不同的脚本用不同的解析方法,比如说对NPC就用NPC固定的脚本,对ITEM就用ITEM固定的脚本,解析完以后就把结果生成底层该物件的结构用于使用。

  而如果要用虚拟机来实现脚本语言的话呢,则会将工程变得无比之巨大,强烈不推荐使用,不过如果你想做一个通用的网络游戏底层的话,则可以考虑设计一个虚拟机。虚拟机大体的解释过程就是进行两次编译,第一次对关键字进行编译,第二次生成汇编语言,然后虚拟机在根据编译生成的汇编语言进行逐行解释,如果大家对这个感兴趣的话,可以去www.mudos.org上下载一份MudOS的原码来研究研究。


作者:wallwind 发表于2013-9-25 13:43:13  原文链接
阅读:70 评论:0  查看评论
 
[转]socket阻塞与非阻塞,同步与异步、I/O模型

socket阻塞与非阻塞,同步与异步

作者:huangguisu

1. 概念理解

     在进行网络编程时,我们常常见到同步(Sync)/异步(Async),阻塞(Block)/非阻塞(Unblock)四种调用方式:
同步:
      
所谓同步,就是在发出一个功能调用时,在没有得到结果之前,该调用就不返回。也就是必须一件一件事做,等前一件做完了才能做下一件事。

例如普通B/S模式(同步):提交请求->等待服务器处理->处理完毕返回 这个期间客户端浏览器不能干任何事

异步:
      
异步的概念和同步相对。当一个异步过程调用发出后,调用者不能立刻得到结果。实际处理这个调用的部件在完成后,通过状态、通知和回调来通知调用者。

     例如 ajax请求(异步)请求通过事件触发->服务器处理(这是浏览器仍然可以作其他事情)->处理完毕

阻塞
     
阻塞调用是指调用结果返回之前,当前线程会被挂起(线程进入非可执行状态,在这个状态下,cpu不会给线程分配时间片,即线程暂停运行)。函数只有在得到结果之后才会返回。

     有人也许会把阻塞调用和同步调用等同起来,实际上他是不同的。对于同步调用来说,很多时候当前线程还是激活的,只是从逻辑上当前函数没有返回而已。 例如,我们在socket中调用recv函数,如果缓冲区中没有数据,这个函数就会一直等待,直到有数据才返回。而此时,当前线程还会继续处理各种各样的消息。

非阻塞
      
非阻塞和阻塞的概念相对应,指在不能立刻得到结果之前,该函数不会阻塞当前线程,而会立刻返回。
对象的阻塞模式和阻塞函数调用
对象是否处于阻塞模式和函数是不是阻塞调用有很强的相关性,但是并不是一一对应的。阻塞对象上可以有非阻塞的调用方式,我们可以通过一定的API去轮询状 态,在适当的时候调用阻塞函数,就可以避免阻塞。而对于非阻塞对象,调用特殊的函数也可以进入阻塞调用。函数select就是这样的一个例子。

 

1. 同步,就是我调用一个功能,该功能没有结束前,我死等结果。
2. 异步,就是我调用一个功能,不需要知道该功能结果,该功能有结果后通知我(回调通知)
3. 阻塞,      就是调用我(函数),我(函数)没有接收完数据或者没有得到结果之前,我不会返回。
4. 非阻塞,  就是调用我(函数),我(函数)立即返回,通过select通知调用者

同步IO和异步IO的区别就在于:数据拷贝的时候进程是否阻塞!

阻塞IO和非阻塞IO的区别就在于:应用程序的调用是否立即返回!


对于举个简单c/s 模式:

同步:提交请求->等待服务器处理->处理完毕返回这个期间客户端浏览器不能干任何事
异步:请求通过事件触发->服务器处理(这是浏览器仍然可以作其他事情)->处理完毕
同步和异步都只针对于本机SOCKET而言的。

同步和异步,阻塞和非阻塞,有些混用,其实它们完全不是一回事,而且它们修饰的对象也不相同。
阻塞和非阻塞是指当进程访问的数据如果尚未就绪,进程是否需要等待,简单说这相当于函数内部的实现区别,也就是未就绪时是直接返回还是等待就绪;

而同步和异步是指访问数据的机制,同步一般指主动请求并等待I/O操作完毕的方式,当数据就绪后在读写的时候必须阻塞(区别就绪与读写二个阶段,同步的读写必须阻塞),异步则指主动请求数据后便可以继续处理其它任务,随后等待I/O,操作完毕的通知,这可以使进程在数据读写时也不阻塞。(等待"通知")

1. Linux下的五种I/O模型

1)阻塞I/O(blocking I/O)
2)非阻塞I/O (nonblocking I/O)
3) I/O复用(select 和poll) (I/O multiplexing)
4)信号驱动I/O (signal driven I/O (SIGIO))
5)异步I/O (asynchronous I/O (the POSIX aio_functions))

前四种都是同步,只有最后一种才是异步IO。


阻塞I/O模型:

        简介:进程会一直阻塞,直到数据拷贝完成

     应用程序调用一个IO函数,导致应用程序阻塞,等待数据准备好。 如果数据没有准备好,一直等待….数据准备好了,从内核拷贝到用户空间,IO函数返回成功指示。

阻塞I/O模型图:在调用recv()/recvfrom()函数时,发生在内核中等待数据和复制数据的过程。


    当调用recv()函数时,系统首先查是否有准备好的数据。如果数据没有准备好,那么系统就处于等待状态。当数据准备好后,将数据从系统缓冲区复制到用户空间,然后该函数返回。在套接应用程序中,当调用recv()函数时,未必用户空间就已经存在数据,那么此时recv()函数就会处于等待状态。

     当使用socket()函数和WSASocket()函数创建套接字时,默认的套接字都是阻塞的。这意味着当调用Windows Sockets API不能立即完成时,线程处于等待状态,直到操作完成。

    并不是所有Windows Sockets API以阻塞套接字为参数调用都会发生阻塞。例如,以阻塞模式的套接字为参数调用bind()、listen()函数时,函数会立即返回。将可能阻塞套接字的Windows Sockets API调用分为以下四种:

    1.输入操作: recv()、recvfrom()、WSARecv()和WSARecvfrom()函数。以阻塞套接字为参数调用该函数接收数据。如果此时套接字缓冲区内没有数据可读,则调用线程在数据到来前一直睡眠。

    2.输出操作: send()、sendto()、WSASend()和WSASendto()函数。以阻塞套接字为参数调用该函数发送数据。如果套接字缓冲区没有可用空间,线程会一直睡眠,直到有空间。

    3.接受连接:accept()和WSAAcept()函数。以阻塞套接字为参数调用该函数,等待接受对方的连接请求。如果此时没有连接请求,线程就会进入睡眠状态。

   4.外出连接:connect()和WSAConnect()函数。对于TCP连接,客户端以阻塞套接字为参数,调用该函数向服务器发起连接。该函数在收到服务器的应答前,不会返回。这意味着TCP连接总会等待至少到服务器的一次往返时间。

  使用阻塞模式的套接字,开发网络程序比较简单,容易实现。当希望能够立即发送和接收数据,且处理的套接字数量比较少的情况下,使用阻塞模式来开发网络程序比较合适。

    阻塞模式套接字的不足表现为,在大量建立好的套接字线程之间进行通信时比较困难。当使用“生产者-消费者”模型开发网络程序时,为每个套接字都分别分配一个读线程、一个处理数据线程和一个用于同步的事件,那么这样无疑加大系统的开销。其最大的缺点是当希望同时处理大量套接字时,将无从下手,其扩展性很差

非阻塞IO模型 

        简介:非阻塞IO通过进程反复调用IO函数( 多次系统调用,并马上返回 ); 在数据拷贝的过程中,进程是阻塞的

       

       我们把一个SOCKET接口设置为非阻塞就是告诉内核,当所请求的I/O操作无法完成时,不要将进程睡眠,而是返回一个错误。这样我们的I/O操作函数将不断的测试数据是否已经准备好,如果没有准备好,继续测试,直到数据准备好为止。在这个不断测试的过程中,会大量的占用CPU的时间。

    把SOCKET设置为非阻塞模式,即通知系统内核:在调用Windows Sockets API时,不要让线程睡眠,而应该让函数立即返回。在返回时,该函数返回一个错误代码。图所示,一个非阻塞模式套接字多次调用recv()函数的过程。前三次调用recv()函数时,内核数据还没有准备好。因此,该函数立即返回WSAEWOULDBLOCK错误代码。第四次调用recv()函数时,数据已经准备好,被复制到应用程序的缓冲区中,recv()函数返回成功指示,应用程序开始处理数据。



     当使用socket()函数和WSASocket()函数创建套接字时,默认都是阻塞的。在创建套接字之后,通过调用ioctlsocket()函数,将该套接字设置为非阻塞模式。Linux下的函数是:fcntl().
    套接字设置为非阻塞模式后,在调用Windows Sockets API函数时,调用函数会立即返回。大多数情况下,这些函数调用都会调用“失败”,并返回WSAEWOULDBLOCK错误代码。说明请求的操作在调用期间内没有时间完成。通常,应用程序需要重复调用该函数,直到获得成功返回代码。

    需要说明的是并非所有的Windows Sockets API在非阻塞模式下调用,都会返回WSAEWOULDBLOCK错误。例如,以非阻塞模式的套接字为参数调用bind()函数时,就不会返回该错误代码。当然,在调用WSAStartup()函数时更不会返回该错误代码,因为该函数是应用程序第一调用的函数,当然不会返回这样的错误代码。

    要将套接字设置为非阻塞模式,除了使用ioctlsocket()函数之外,还可以使用WSAAsyncselect()和WSAEventselect()函数。当调用该函数时,套接字会自动地设置为非阻塞方式。

  由于使用非阻塞套接字在调用函数时,会经常返回WSAEWOULDBLOCK错误。所以在任何时候,都应仔细检查返回代码并作好对“失败”的准备。应用程序连续不断地调用这个函数,直到它返回成功指示为止。上面的程序清单中,在While循环体内不断地调用recv()函数,以读入1024个字节的数据。这种做法很浪费系统资源。

    要完成这样的操作,有人使用MSG_PEEK标志调用recv()函数查看缓冲区中是否有数据可读。同样,这种方法也不好。因为该做法对系统造成的开销是很大的,并且应用程序至少要调用recv()函数两次,才能实际地读入数据。较好的做法是,使用套接字的“I/O模型”来判断非阻塞套接字是否可读可写。

    非阻塞模式套接字与阻塞模式套接字相比,不容易使用。使用非阻塞模式套接字,需要编写更多的代码,以便在每个Windows Sockets API函数调用中,对收到的WSAEWOULDBLOCK错误进行处理。因此,非阻塞套接字便显得有些难于使用。

    但是,非阻塞套接字在控制建立的多个连接,在数据的收发量不均,时间不定时,明显具有优势。这种套接字在使用上存在一定难度,但只要排除了这些困难,它在功能上还是非常强大的。通常情况下,可考虑使用套接字的“I/O模型”,它有助于应用程序通过异步方式,同时对一个或多个套接字的通信加以管理。


IO复用模型:

             简介:主要是select和epoll;对一个IO端口,两次调用,两次返回,比阻塞IO并没有什么优越性;关键是能实现同时对多个IO端口进行监听;

      I/O复用模型会用到select、poll、epoll函数,这几个函数也会使进程阻塞,但是和阻塞I/O所不同的的,这两个函数可以同时阻塞多个I/O操作。而且可以同时对多个读操作,多个写操作的I/O函数进行检测,直到有数据可读或可写时,才真正调用I/O操作函数


信号驱动IO

    简介:两次调用,两次返回;

    首先我们允许套接口进行信号驱动I/O,并安装一个信号处理函数,进程继续运行并不阻塞。当数据准备好时,进程会收到一个SIGIO信号,可以在信号处理函数中调用I/O操作函数处理数据。


异步IO模型

         简介:数据拷贝的时候进程无需阻塞。

     当一个异步过程调用发出后,调用者不能立刻得到结果。实际处理这个调用的部件在完成后,通过状态、通知和回调来通知调用者的输入输出操作


同步IO引起进程阻塞,直至IO操作完成。
异步IO不会引起进程阻塞。
IO复用是先通过select调用阻塞。


5个I/O模型的比较:



1. select、poll、epoll简介

epoll跟select都能提供多路I/O复用的解决方案。在现在的Linux内核里有都能够支持,其中epoll是Linux所特有,而select则应该是POSIX所规定,一般操作系统均有实现

select:

select本质上是通过设置或者检查存放fd标志位的数据结构来进行下一步处理。这样所带来的缺点是:

1、 单个进程可监视的fd数量被限制,即能监听端口的大小有限。

      一般来说这个数目和系统内存关系很大,具体数目可以cat /proc/sys/fs/file-max察看。32位机默认是1024个。64位机默认是2048.

2、 对socket进行扫描时是线性扫描,即采用轮询的方法,效率较低:

       当套接字比较多的时候,每次select()都要通过遍历FD_SETSIZE个Socket来完成调度,不管哪个Socket是活跃的,都遍历一遍。这会浪费很多CPU时间。如果能给套接字注册某个回调函数,当他们活跃时,自动完成相关操作,那就避免了轮询,这正是epoll与kqueue做的。

3、需要维护一个用来存放大量fd的数据结构,这样会使得用户空间和内核空间在传递该结构时复制开销大

poll:

poll本质上和select没有区别,它将用户传入的数组拷贝到内核空间,然后查询每个fd对应的设备状态,如果设备就绪则在设备等待队列中加入一项并继续遍历,如果遍历完所有fd后没有发现就绪设备,则挂起当前进程,直到设备就绪或者主动超时,被唤醒后它又要再次遍历fd。这个过程经历了多次无谓的遍历。

它没有最大连接数的限制,原因是它是基于链表来存储的,但是同样有一个缺点:

1、大量的fd的数组被整体复制于用户态和内核地址空间之间,而不管这样的复制是不是有意义。                                                                                                                                      2、poll还有一个特点是“水平触发”,如果报告了fd后,没有被处理,那么下次poll时会再次报告该fd。

epoll:

epoll支持水平触发和边缘触发,最大的特点在于边缘触发,它只告诉进程哪些fd刚刚变为就需态,并且只会通知一次。还有一个特点是,epoll使用“事件”的就绪通知方式,通过epoll_ctl注册fd,一旦该fd就绪,内核就会采用类似callback的回调机制来激活该fd,epoll_wait便可以收到通知

epoll的优点:

1、没有最大并发连接的限制,能打开的FD的上限远大于1024(1G的内存上能监听约10万个端口);
2、效率提升,不是轮询的方式,不会随着FD数目的增加效率下降。只有活跃可用的FD才会调用callback函数;
       即Epoll最大的优点就在于它只管你“活跃”的连接,而跟连接总数无关,因此在实际的网络环境中,Epoll的效率就会远远高于select和poll。
3、 内存拷贝,利用mmap()文件映射内存加速与内核空间的消息传递;即epoll使用mmap减少复制开销。

select、poll、epoll 区别总结:

1、支持一个进程所能打开的最大连接数

select

单个进程所能打开的最大连接数有FD_SETSIZE宏定义,其大小是32个整数的大小(在32位的机器上,大小就是32*32,同理64位机器上FD_SETSIZE为32*64),当然我们可以对进行修改,然后重新编译内核,但是性能可能会受到影响,这需要进一步的测试。

poll

poll本质上和select没有区别,但是它没有最大连接数的限制,原因是它是基于链表来存储的

epoll

虽然连接数有上限,但是很大,1G内存的机器上可以打开10万左右的连接,2G内存的机器可以打开20万左右的连接

2、FD剧增后带来的IO效率问题

select

因为每次调用时都会对连接进行线性遍历,所以随着FD的增加会造成遍历速度慢的“线性下降性能问题”。

poll

同上

epoll

因为epoll内核中实现是根据每个fd上的callback函数来实现的,只有活跃的socket才会主动调用callback,所以在活跃socket较少的情况下,使用epoll没有前面两者的线性下降的性能问题,但是所有socket都很活跃的情况下,可能会有性能问题。

3、 消息传递方式

select

内核需要将消息传递到用户空间,都需要内核拷贝动作

poll

同上

epoll

epoll通过内核和用户空间共享一块内存来实现的。

总结:

综上,在选择select,poll,epoll时要根据具体的使用场合以及这三种方式的自身特点。

1、表面上看epoll的性能最好,但是在连接数少并且连接都十分活跃的情况下,select和poll的性能可能比epoll好,毕竟epoll的通知机制需要很多函数回调。

2、select低效是因为每次它都需要轮询。但低效也是相对的,视情况而定,也可通过良好的设计改善


作者:wallwind 发表于2013-9-14 0:03:07  原文链接
阅读:91 评论:0  查看评论
 
[转]linux下安装protobuf教程+示例(详细)
1 在网站 http://code.google.com/p/protobuf/downloads/list上可以下载 Protobuf 的源代码。然后解压编译安装便可以使用它了。
安装步骤如下所示:
 tar -xzf protobuf-2.1.0.tar.gz 
 cd protobuf-2.1.0 
 ./configure --prefix=/usr/local/protobuf
 make 
 make check 
 make install 
 
 2 > sudo vim /etc/profile
 添加
export PATH=$PATH:/usr/local/protobuf/bin/
export PKG_CONFIG_PATH=/usr/local/protobuf/lib/pkgconfig/
保存执行
source /etc/profile

同时 在~/.profile中添加上面两行代码,否则会出现登录用户找不到protoc命令

3 > 配置动态链接库路径
sudo vim /etc/ld.so.conf
插入:
/usr/local/protobuf/lib

4 > su  #root 权限
ldconfig

5> 写消息文件:msg.proto
  1. package lm;   
  2. message helloworld   
  3. {   
  4.     required int32     id = 1;  // ID     
  5.     required string    str = 2;  // str    
  6.     optional int32     opt = 3;  //optional field   
  7. }  
将消息文件msg.proto映射成cpp文件
protoc -I=. --cpp_out=. msg.proto
可以看到生成了
msg.pb.h 和msg.pb.cc

6> 写序列化消息的进程
write.cc
  1. #include "msg.pb.h"  
  2. #include <fstream>  
  3. #include <iostream>  
  4. using namespace std;  
  5.   
  6. int main(void)   
  7. {   
  8.   
  9.     lm::helloworld msg1;   
  10.     msg1.set_id(101);   
  11.     msg1.set_str("hello");   
  12.     fstream output("./log", ios::out | ios::trunc | ios::binary);   
  13.   
  14.     if (!msg1.SerializeToOstream(&output)) {   
  15.         cerr << "Failed to write msg." << endl;   
  16.         return -1;   
  17.     }          
  18.     return 0;   
  19. }  
编译 write.cc 
 g++  msg.pb.cc write.cc -o write  `pkg-config --cflags --libs protobuf` -lpthread
 
执行write 
./write, 可以看到生成了log文件

7> 写反序列化的进程
reader.cc
  1. #include "msg.pb.h"  
  2. #include <fstream>  
  3. #include <iostream>  
  4. using namespace std;  
  5.   
  6. void ListMsg(const lm::helloworld & msg) {    
  7.     cout << msg.id() << endl;   
  8.     cout << msg.str() << endl;   
  9. }   
  10.   
  11. int main(int argc, char* argv[]) {   
  12.   
  13.     lm::helloworld msg1;   
  14.   
  15.     {   
  16.         fstream input("./log", ios::in | ios::binary);   
  17.         if (!msg1.ParseFromIstream(&input)) {   
  18.             cerr << "Failed to parse address book." << endl;   
  19.             return -1;   
  20.         }         
  21.     }   
  22.   
  23.     ListMsg(msg1);   
  24. }  
编译:g++  msg.pb.cc reader.cc -o reader  `pkg-config --cflags --libs protobuf` -lpthread
执行./reader 输出 :
101
hello

8> 写Makefile文件
  1. all: write reader  
  2.   
  3. clean:  
  4.     rm -f write reader msg.*.cc msg.*.h *.o  log  
  5.   
  6. proto_msg:  
  7.     protoc --cpp_out=. msg.proto  
  8.   
  9.   
  10. write: msg.pb.cc write.cc  
  11.     g++  msg.pb.cc write.cc -o write  `pkg-config --cflags --libs protobuf`  
  12.   
  13. reader: msg.pb.cc reader.cc  
  14.     g++  msg.pb.cc reader.cc -o reader  `pkg-config --cflags --libs protobuf`  

作者:wallwind 发表于2013-9-10 0:10:20  原文链接
阅读:79 评论:0  查看评论
 
[原]priority_queue 复习学习
  1. #include "stdafx.h"  
  2. #include <iostream>  
  3. #include <stdlib.h>  
  4. #include <stdio.h>  
  5. #include <queue>  
  6. #include <vector>  
  7.   
  8. using namespace std;  
  9. class myClass  
  10. {  
  11. public:  
  12.     myClass()  
  13.     {  
  14.         cout<<"myClass()"<<endl;  
  15.     }  
  16.   
  17.     ~myClass()  
  18.     {  
  19.         cout<<"~myClass()"<<endl;  
  20.     }  
  21.   
  22. };  
  23.   
  24. typedef struct    
  25. {  
  26.     int m;  
  27.     int n;  
  28. } mystruct;  
  29.  bool operator > (const mystruct &m1,const mystruct &m2)  
  30. {  
  31.     return m1.m > m2.m;  
  32. }  
  33. priority_queue<mystruct,vector<mystruct> ,std::greater<mystruct> > myPq;  
  34. int _tmain(int argc, _TCHAR* argv[])  
  35. {  
  36.     mystruct m1;  
  37.     m1.m=10;  
  38.     mystruct m2;  
  39.     m2.m=20;  
  40.   
  41.     myPq.push(m1);  
  42.     myPq.push(m2);  
  43.     while(!myPq.empty())  
  44.     {  
  45.         cout<< myPq.top().m <<endl;  
  46.         myPq.pop();  
  47.     }  
  48.     return 0;  
  49. }  
  1.   
  1.   
  1. 使用<pre name="code" class="cpp">priority_queue 队列,按照你定的关键字进行排序。</pre><pre name="code" class="cpp"></pre><pre name="code" class="cpp"><pre name="code" class="cpp">template <class T, class Container = vector<T>,  
  2.   class Compare = less<typename Container::value_type> > class priority_queue;</pre><br><br><pre></pre><pre></pre><p>通过定义可以知道,把你的数据放到了自定的容器中,需要注意的</p><pre name="code" class="cpp">class Container = vector<T> 默认的这种容器,选择其他容器要谨慎,</pre><pre name="code" class="cpp">如果按照自己的容器来排序,那么就要重载<pre name="code" class="cpp">operator > 操作符。</pre><pre></pre><p></p>            <div>                作者:wallwind 发表于2013-9-6 12:01:13 <a target="_blank" href="http://blog.csdn.net/wallwind/article/details/11209807" style="color:rgb(0,153,238); text-decoration:none">原文链接</a>            </div>            <div>            阅读:70 评论:0 <a target="_blank" href="http://blog.csdn.net/wallwind/article/details/11209807#comments" style="color:rgb(0,153,238); text-decoration:none">查看评论</a>            </div>                </pre></pre>  
 
[转]MMORPG服务器架构
一.摘要

1.网络游戏 MMORPG 整体服务器框架,包括早期,中期,当前的一些主流架构
2.网络游戏网络层,包括网络 协议 , IO 模型,网络框架,消息编码等。
3.网络游戏的 场景 管理, AI 脚本 的应用等。
4. 开源 的网络服务器引擎
5.参考书籍,博客

二.关键词

网络协议 网络IO 消息 广播 同步 CS TCP/UDP IP 集群 负载均衡 分布式 
网关服务器 GateServer 心跳 多线程/线程池 开源网络通讯框架/模型

阻塞/非阻塞/同步/异步    Proactor/Reactor/Actor Select/Poll/Epoll/Iocp/Kqueue 
游戏开发中的设计模式/数据结构

短连接和长连接 游戏安全 缓存 消息编码协议 脚本语言 
Socket Nagle/粘包/截断/TCP_NODELAY AI/场景 分线/分地图 开源MMORPG服务器


三.正文 框架结构

1.    早期的MMORPG服务器结构

Client<->GameServer<->DB    所有业务,数据集中处理

优点 :简单,快速开发
缺点:
     1.所有业务放在一起,系统负担大大增加.一个bug可能导致整个服务器崩溃,造成所有玩家掉线甚至丢失等严重后果。
     2.开服一刹那,所有玩家全部堆积在同一个新手村.->>>>卡,客户端卡(同屏人数过多渲染/广播风暴) 服务器卡(处理大量同场景消息/广播风暴)
2.    中期-用户分离集群式

                GameServe1
Client            |                    DB
                GameServer2

玩家不断增多-> 分线 ->程序自动或玩家手动选择进入
缺点 :运营到后期,随着每条线玩家的减少, 互动大大减少。

3.    中后期 数据分离集群式
按地图划分服务器,当前主流
     新手村问题 :《 天龙八部 》提出了较好的解决方案,建立多个平行的新手村地图,一主多副,开服时尽可能多的同时容纳新用户的涌入,高等级玩家从其它地图回新手村只能到达主新手村。

4.    当前主流的网络游戏架构


         注:在GateServer和CenterServer之间是有一条TCP连接的。而GameServer和LogServer之间的连接可以是UDP连接。这是有一个大概的图,很多地方需要细化。
GateServer:网关服务器,AgentServer、ProxyServer

  优点
    (1)作为网络通信的中转站,负责维护将内网和外网隔离开,使外部无法直接访问内部服务器,保障内网服务器的安全,一定程度上较少外挂的攻击。
    (2)网关服务器负责解析数据包、加解密、超时处理和一定逻辑处理,这样可以提前过滤掉错误包和非法数据包。
    (3)客户端程序只需建立与网关服务器的连接即可进入游戏,无需与其它游戏服务器同时建立多条连接,节省了客户端和服务器程序的网络资源开销。
    (4)在玩家跳服务器时,不需要断开与网关服务器的连接,玩家数据在不同游戏服务器间的切换是内网切换,切换工作瞬问完成,玩家几乎察觉不到,这保证了游戏的流畅性和良好的用户体验。

    缺点
1.网关服务器成为高负载情况下的通讯瓶颈问题
2由于网关的单节点故障导致整组服务器无法对外提供服务的问题

    解决: 多网关 技术。顾名思义,“多网关” 就是同时存在多个网关服务器,比如一组服务器可以配置三台GameGme。当负载较大时,可以通过增加网关服务器来增加网关的总体通讯流量,当一台网关服务器宕机时,它只会影响连接到本服务器的客户端,其它客户端不会受到任何影响。

DCServer :数据中心服务器。主要的功能是缓存玩家角色数据,保证角色数据能快速的读取和保存
CenterServer :全局服务器/中心服务器,也叫 WorldServer . 主要负责维持GameServer之间数据的转发和数据广播。另外一些游戏系统也可能会放到Center上处理,比如好友系统,公会系统。

     改进 :将网关服务器细化为LogingateServer和多个GameGateServer.

5.    按 业务分离式 集群
由于网络游戏存在很多的业务,如聊天,战斗,行走,NPC等,可以将某些业务分到单独的服务器上。这样每个服务器的程序则会精简很多。而且一些大流量业务的分离,可以有效的提高游戏服务器人数上限。

 

优点:

      1.业务的分离使得每种服务器的程序变的简单,这样可以降低出错的几率。即使出错,也不至于影响到每一个整个游戏的进行,而且通过快速启动另一台备用服务器替换出错的服务器。
     2.业务的分离使得流量得到了分散,进而相应速度回得到提升 。
     3.大部分业务都分离了成了单独的服务器,所以可以动态的添加,从而提高人数上限。

改进 :甚至可以将登陆服务器细化拆分建角色,选择角色服务器

6.     一种简单实用的网络游戏服务器架构

下图中每个方框表示一个独立的进程APP组件,每个服务进程如果发生宕机会影响部分用户,整体服务但不会全部中断。在宕机进程重启后,又可以并入整体,全部服务得以继续。



gls :game login server,游戏登录服务器,某种程序上,其不是核心组件,gls调用外部的接口,进行基本的用户名密码认证。此外需要实现很多附属的功能:登录排队(对开服非常有帮助),GM超级登录通道(GM可以不排队进入游戏),封测期间激活用户控制,限制用户登录,控制客户端版本等。
db :实质上是后台sql的 大内存缓冲 ,隔离了数据库操作,比较内存中的数据,只把改变的数据定时批量写入sql。系统的算法,开发稳定性都要求非常高。
center :所有组件都要在这里注册,在线玩家的session状态都在这里集中存放,和各组件有心跳连接。所有对外的接口也全部通过这里。
角色入口:玩家登录游戏后的选择角色
gs :game server,最核心组件,同一地图,所有游戏逻辑相关的功能,都在这里完成。
gate :建立和用户的常链接,主要作sockt转发,屏蔽恶意包,对gs进行保护。协议加密解密功能,一个gate共享多个gs,降低跳转地图连接不上的风险。
IM,关系,寄售:表示其它组件,负责对应的跨地图发生全局的游戏逻辑。

7. 另一个架构图


     1-   这是一条 WebService 的管道,在用户激活该区帐号,或者修改帐号密码的时候,通过这条通道来插入和更新用户的帐号信息。
     2-   这也是一条 WebService 管道,用来获取和控制用户该该组内的角色信息,以及进行付费商城代币之类的更新操作。
     3-   这是一条 本地的TCP/IP 连接,这条连接主要用来进行服务器组在登陆服务器的注册,以及登陆服务器验证帐户后,向用户服务器注册帐户登陆信息,以及进行对已经登陆的帐户角色信息进行操作(比如踢掉当前登陆的角色),还有服务器组的信息更新(当前在线玩家数量等)。
     4-   这也是一条 本地TCP/IP 连接,这条连接用来对连接到GameServer的客户端进行验证,以及获取角色数据信息,还有传回GameServer上角色的数据信息改变。
     5-   这条连接也是一条 本地的TCP/IP 连接,它用来进行公共信息服务器和数个游戏服务器间的交互,用来交换一些游戏世界级的信息(比如公会信息,跨服组队信息,跨服聊天频道等)。
     6-   这里的两条连接,想表达的意思是,UserServer和GameServer的Agent是可以互换使用的,也就是玩家进入组内之后,就不需要再切换Agent。如果不怕乱套,也可以把登陆服务器的Agent也算上,这样用户整个过程里就不需要再更换Agent,减少重复连接的次数,也提高了稳定性。(毕竟连接次数少了,也降低了连不上服务器的出现几率)
在这个架构里面,GameServer实际上是一个游戏逻辑的综合体,里面可以再去扩展成几个不同的逻辑服务器,通过PublicServer进行公共数据交换。
     UserServer 实际上扮演了一个ServerGroup的领头羊的角色,它负责向LoginServer注册和更新服务器组的信息(名字,当前人数),并且对Agent进行调度,对选择了该组的玩家提供一个用户量最少的Agent。同时,它也兼了一个角色管理服务器的功能,发送给客户端当前的角色列表,角色的创建,删除,选择等管理操作,都是在这里进行的。而且,它还是一个用户信息的验证服务器,GameServer需要通过它来进行客户端的合法性验证,以及获取玩家选择的角色数据信息。
采用这种架构的游戏,通常有以下表现。
     1- 用户必须激活一个大区,才能在大区内登陆自己的帐号。
     2- 用户启动客户端的时候,弹出一个登陆器,选择大区。
     3- 用户启动真正的客户端的时候,一开始就是输入帐号密码。
     4- 帐号验证完成之后,进行区内的服务器选择。
     5- 服务器选择完成之后,进入角色管理。同时,角色在不同的服务器里不能共享。
四.正文网络通讯

1. 网络协议
 根据游戏类型    实时性要求/是否允许丢包 来决定  TCP/UDP 协议

a. TCP:面向连接,可靠,保证顺序,慢,有延迟
     TCP每次发送一个数据包后都要等待接收方发送一个应答信息,这样TCP才可以确认数据包通过因特网完整地送到了接收方。如果在一段时间内TCP没有收到接收方的应答,他就会停止发送新的数据包,转而去重新发送没有收到应答2的数据包,并且持续这种发送状态知道收到接收方的应答。所以这会造成网络数据传输的延迟,若网络情况不好,发送方会等待相当长一段时间
        UDP:无连接,不可靠,不保证顺序,快

b. 长连接/短连接
长连接,指在一个TCP连接上可以连续发送多个数据包,在TCP连接保持期间,如果没有数据包发送,需要双方发检测包以维持此连接,一般需要自己做在线维
    连接→数据传输→保持连接(心跳)→数据传输→保持连接(心跳)→……→关闭连接
短连接是指通信双方有数据交互时,就建立一个TCP连接,数据发送完成后,则断开此TCP连接,如Http
    连接→数据传输→关闭连接

2. IO模型

        Unix5中io模型
1.    阻塞IO (Blocking I/O Model)
2.    非阻塞IO (Nonblocking I/O Model)
3.    IO复用 (I/O Multiplexing Model)
4.    信号驱动IO (Signal-Driven I/O Model)
5.    异步IO (Asynchronous I/O Model)

IO分两个阶段:
1.通知内核准备数据。2.数据从内核缓冲区拷贝到应用缓冲区

根据这2点IO类型可以分成:
     1.阻塞IO,在两个阶段上面都是阻塞的。
     2.非阻塞IO,在第1阶段,程序不断的轮询直到数据准备好,第2阶段还是阻塞的
     3.IO复用,在第1阶段,当一个或者多个IO准备就绪时,通知程序,第2阶段还是阻塞的,在第1阶段还是轮询实现的,只是所有的IO都集中在一个地方,这个地方进行轮询
     4.信号IO,当数据准备完毕的时候,信号通知程序数据准备完毕,第2阶段阻塞
     5.异步IO,1,2都不阻塞







   
同时阻塞多个I/O操作。而且可以同时对多个读操作,多个写操作的I/O函数进行检测,直到有数据可读或可写时,才真正调用I/O操作函数
J ava#Selector

   

允许套接口进行信号驱动I/O,并安装一个信号处理函数,进程继续运行并不阻塞。当数据准备好时,进程会收到一个SIGIO信号,可以在信号处理函数中调用I/O操作函数处理数据.


J ava#NIO2
发出系统调用后,直接返回。通知IO操作完成。
前四种同步IO,最后一种异步IO.二者区别:第二个阶段必须要求进程主动调用recvfrom.而异步io则将io操作全部交给内核完成,完成后发信号通知。此期间,用户不需要去检查IO操作的状态,也不需要主动的去拷贝数据。

3. 线程阻塞的原因:

     1.Thread.sleep(),线程放弃CPU,睡眠N秒,然后恢复运行
     2.线程要执行一段同步代码,由于无法获得相关的锁,阻塞。获得同步锁后,才可以恢复运行。
     .线程执行了一个对象的wait方法,进入阻塞状态,只有等到其他线程执行了该对象的notify、nnotifyAll,才能将其唤醒。
     4.IO操作,等待相关资源
阻塞线程的共同特点是:放弃CPU,停止运行,只有等到导致阻塞的原因消除,才能恢复运行 。或者被其他线程中断,该线程会退出阻塞状态,并抛出InterruptedException.

4.
阻塞/非阻塞/同步/异步
同步/异步关注的是消息如何通知的机制。而阻塞和非阻塞关注的是处理消息。是两组完全不同的概念。

5.几个常用概念
Select Poll
Epoll(Linux) Kqueue(FreeBSD)   
IOCP    windows
 
Reactor
Dispatcher(分发器),Notifer(通知器), 事件到来时,使用Dispatcher(分发器)对Handler进行分派,这个Dispatcher要对所有注册的Handler进行维护。同时,有一个Demultiplexer(分拣器)对多路的同步事件进行分拣。    

Proactor
Proactor和Reactor都是并发编程中的设计模式.用于派发/分离IO操作事件的 。这里所谓的IO事件也就是诸如read/write的IO操作。"派发/分离"就是将单独的IO事件通知到上层模块。两个模式不同的地方在于,Proactor用于异步IO,而Reactor用于同步IO。

两个模式的相同点,都是对某个IO事件的事件通知(即告诉某个模块,这个IO操作可以进行或已经完成)。在结构上,两者也有相同点:demultiplexor负责提交IO操作(异步)、查询设备是否可操作(同步),然后当条件满足时,就回调handler。
不同点在于,异步情况下(Proactor),当回调handler时,表示IO操作已经完成;同步情况下(Reactor),回调handler时,表示IO设备可以进行某个操作(can read or can write),handler这个时候开始提交操作。

6.
网络通讯框架
TCP Server框架:
Apache  MINA (Multipurpose Infrastructure for Network Applications)2.0.4
Netty  3.5.0Final
Grizzly  2.2
Quickserver 是一个免费的开源Java库,用于快速创建健壮的多线程、多客户端TCP服务器应用程序。使用QuickServer,用户可以只集中处理应用程序的逻辑/协议
Cindy  强壮,可扩展,高效的异步I/O框架
xSocket 一个轻量级的基于nio的服务器框架用于开发高性能、可扩展、多线程的服务器。该框架封装了线程处理、异步读/写等方面
ACE  6.1.0 C++ADAPTIVE CommunicationEnvironment,
SmaxFoxServer  2.X 专门为Adobe Flash设计的跨平台socket服务器

7. 消息编码协议
AMF/JSON/XML/自定义/ProtocolBuffer

无论是做何种网络应用,必须要解决的问题之一就是 应用层从字节流中拆分出消息 的问题,也就是对于 TCP 这种字节流协议,接收方应用层能够从字节流中识别发送方传输的消息.
1.使用特殊字符或者字符串作为消息的边界,应用层解析收到的字节流时,遇见此字符或者字符串则认为收到一个完整的消息 
2.为每个消息定义一个长度,应用层收到指定长度的字节流则认为收到了一个完整的消息
消息分隔标识(separator)、消息头(header)、消息体(body)
 len | message_id | data 

 |separator |     header   | body |
  | len       | message_id | data

8.  粘包:
TCP粘包是指发送方发送的若干包数据到接收方接收时粘成一包,从接收缓冲区看,后一包数据的头紧接着前一包数据的尾。 
     1.发送方引起的粘包是由TCP协议本身造成的,TCP为提高传输效率,发送方往往要收集到足够多的数据后才发送一包数据。若连续发送几次的数据都很少,通常TCP会根据优化算法把这些数据合成一包后一次发送出去,这样接收方就收到了粘包数据。
     2.接收方引起的粘包是由于接收方用户进程不及时接收数据,从而导致粘包现象。这是因为接收方先把收到的数据放在系统接收缓冲区,用户进程从该缓冲区取数据,若下一包数据到达时前一包数据尚未被用户进程取走,则下一包数据放到系统接收缓冲区时就接到前一包数据之后,而用户进程根据预先设定的缓冲区大小从系统接收缓冲区取数据,这样就一次取到了多包数据

解决措施:
     1.对于发送方引起的粘包现象,用户可通过编程设置来避免,TCP提供了强制数据立即传送的操作指令push,TCP软件接收到该操作指令后,就立即将本段数据发送出去,而不必等待发送缓冲区满;
TCP-NO-DELAY-关闭了优化算法,不推荐
     2.对于接收方引起的粘包,则可通过优化程序设计、精简接收进程工作量、提高接收进程优先级等措施,使其及时接收数据,从而尽量避免出现粘包现象-当发送频率高时依然可能出现粘包
     3.接收方控制,将一包数据按结构字段,人为控制分多次接收,然后合并,通过这种手段来避免粘包。-效率低
     4.接收方创建一预处理线程,对接收到的数据包进行预处理,将粘连的包分开

分包算法思路:
基本思路是首先将待处理的接收数据(长度设为m)强行转换成预定的结构数据形式,并从中取出数据结构长度字段,即n,而后根据n计算得到第一包数据长度
1) 若n<m,则表明数据流包含多包数据,从其头部截取n个字节存入临时缓冲区,剩余部分数据一次继续循环处理,直至结束。
2) 若n=m,则表明数据流内容恰好是一完整结构数据,直接将其存入临时缓冲区即可。
3) 若n>m,则表明数据流内容尚不够构成一个完整结构数据,需留待与下一包数据合并后再行处理。

.正文之场景管理、ai 、脚本

  AOI : (Area Of Interest),广义上,AOI系统支持任何游戏世界中的物体个体对一定半径范围内发生的事件进行处理;但MMOPRG上绝大多数需求只是对半径范围内发生的物体离开/进入事件进行处理。当你进入一个游戏场景时,如果你能看到其他玩家,那背后AOI系统就正在运作.

     1. 很容易想象,AOI的需求最简单的做法是全世界玩家信息全部同步给客户端。这个方案是O(n^2)的复杂度,对服务器来说是不能承受之重。但如果是超小地图十人以下的特殊需求倒可能是个简洁的方案。
     2. 比较流行的方案是网格法,简单,高效:将地图按设定的格子大小划分为网格,设玩家移动到某坐标,我们很容易地将玩家归入该坐标所属的网格G的玩家链中,而这个玩家的可见集可以简单地将以网格G为中心的九宫格中的玩家链聚合而得到。而要获得两次移动间的可见集差异,也非难事.

转自云风Blog:
所谓 AOI ( Area Of Interest ) ,大致有两个用途。
     一则是解决  NPC 的 AI 事件触发问题 。游戏场景中有众多的 NPC ,比 PC 大致要多一个数量级。NPC 的 AI 触发条件往往是和其它 NPC 或 PC 距离接近。如果没有 AOI 模块,每个 NPC 都需要遍历场景中其它对象,判断与之距离。这个检索量是非常巨大的(复杂度 O(N*N) )。一般我们会设计一个 AOI 模块,统一处理,并优化比较次数,当两个对象距离接近时,以消息的形式通知它们。
     二则用于 减少向 PC 发送的同步消息数量 。把离 PC 较远的物体状态变化的消息过滤掉。PC 身上可以带一个附近对象列表,由 AOI 消息来增减这个列表的内容。
在服务器上,我们一般推荐把 AOI 模块做成一个独立服务 。场景模块通知它改变对象的位置信息。AOI 服务则发送 AOI 消息给场景
AOI 的传统实现方法大致有三种:

第一,也是最苯的方案。直接定期比较所有对象间的关系,发现能够触发 AOI 事件就发送消息。这种方案实现起来相当简洁,几乎不可能有 bug ,可以用来验证服务协议的正确性。在场景中对象不对的情况下其实也是不错的一个方案。如果我们独立出来的话,利用一个单独的核,其实可以定期处理相当大的对象数量。

第二,空间切割监视的方法。把场景划分为等大的格子,在每个格子里树立灯塔。在对象进入或退出格子时,维护每个灯塔上的对象列表。对于每个灯塔还是 O(N * N) 的复杂度,但由于把对象数据量大量降了下来,所以性能要好的多,实现也很容易。缺点是,存储空间不仅和对象数量有关,还和场景大小有关。更浪费内存。且当场景规模大过对象数量规模时,性能还会下降。因为要遍历整个场景。对大地图不太合适。这里还有一些优化技巧,比如可以把格子划分为六边形 的。

第三,使用十字链表 (3d 空间则再增加一个链表维度) 保存一系列线段,当线段移动时触发 AOI 事件。算法不展开解释,这个用的很多应该搜的到。优点是可以混用于不同半径的 AOI 区域。

2.AI
    1.怪物AI
    2.NPC AI
    3.世界环境AI
实现方法: 状态机
 其他:
 寻路:A*
 神经网络
 遗传算法

3. 脚本语言的选择:
Lua/Python/Erlang
Groovy/JRuby/Scala/Fantom/JPython-五大基于JVM的语言
  作用:可应用于部分应用层逻辑经常发生变化的系统。如任务系统。以在不需要重新编译整个工程的情况下调整、 测试和修改游戏运行的机制和特性
.正文之开源网络游戏服务器

魔兽世界模拟器
mangosTrinity  TrinityCore2

天堂2模拟器
L2J

永恒之塔模拟器

Arianne

七.正文之参考书籍,博客
1. 云风Blog  ttp://codingnow.com/
2.书籍<大型多人在线游戏开发><网络游戏服务器编程><UNIX网络编程>

注:有部分内容来自网络,谢谢你们!
作者:wallwind 发表于2013-8-30 0:42:56  原文链接
阅读:221 评论:0  查看评论
 
[原]游戏架构之前端接入层

在前边几篇文章已经给大家讲过,我们游戏通过进程间异步通信的方式来实现瓶颈的最大程度的减小。


前端接入层主要的做什么呢?

主要是数据包的有效性验证和维持与玩家的长连接。


如何做有效性的验证,我们使用过和前端具体的协议定制。然后通过我们具体的协议包头+包体的来计算。

如果我们的计算和我们协议不一致,直接就断开和客户端的连接,发送rst信号,客户端会被通知到,进程会hub状态,


在这里,我们这个数据包分析是可以通过数据type来进行调用具体的数据分析函数,如果是我们的tcp设计的协议,就会走auth回调函数,如果是http,那么就走parshttp协议。等


那我们如何维持长连接。


当前端接入,我们会对每个accept后的操作符进行分配一段内存,保持连接,等到这个操作符上有数据,就会通过具体的内存来进行一些操作了。

如果该操作符有错误,那么我们就会通过epoll_clt 来进行删除该操作符,然后同时删除该段内存。


具体的 epoll代码实现我就不再详细输了,基本模式都是epoll_create,初始化后,我们在不停循环下,使用epoll_wait,监听事件,进行从数据操作符上读取数据,然后分析,和我们的业务进程进行通信。


而其他业务进程, 也会不停的从消息队列里去数据。 这样,我们就实现了 网络模块和业务模块的有效分离。使得我们的业务在单进程单线程下哦能每秒钟处理20万个数据包。


目前我们外网玩家压力测试能达到4000多人,cpu在80%了。


在网络模块,我们需要深刻理解socket下的一些很多细节,不如我们发送数据是否全部从发送缓冲区发送出去等,要发送什么信号给接入socket等等。需要很长时间一段时间积累才能理解到位。


本章节描述不是很深刻。我是主要负责改动,一本原型差不多没什么大问题了。

更多文章,欢迎访问:

http://blog.csdn.net/wallwind



作者:wallwind 发表于2013-8-30 0:17:19  原文链接
阅读:461 评论:0  查看评论
 
[原]mmog游戏开发之业务篇

这周不是很忙,因为我们的游戏开发了近一年,由于公司的业务调整,在游戏开第二服的时候,老板果断的把项目停到了。

感觉超级的不爽啊。因为这个游戏项目像我的孩子一样和我一样成长,里边的大概的业务逻辑偶都是实现过的,比如

基础系统里的,任务系统,背包系统,公会系统,相关副本系统,商城系统,人物属性相关,运镖,怪物AI,技能相关,地图等等,差不多游戏里的所有业务我都有所涉及


那么对于一个业务是如何设计,如何少的产生bug,和一些漏洞呢。

任务系统

在这里我表述一下,如果是C语言的开发,那么任务系统,属于玩家一个的一个必须要序记住的的数据,因为,我们要在玩家身上找到。

好,那我们就记下来了任务ID,但是我们发现一个游戏系统有很多类型任务,比如

主线任务----主要特征表现在必须做的,不可以放弃的。一般主线任务,同一时刻只允许纯在一种

支线任务----同时可以做很多,同时可以接受很多

循环类型任务-----这个任务可以一天循环做几次。

等等。

因此在设计表的时候涉及到类型,次数也要涉及。还有完成条件,打怪的个数,等等能所有能涉及到的东西。


涉及协议相关的时候,我们要知道任务分为几种状态,可以接,接受状态,运行状态,放弃状态,完成状太。

那么,我们就要涉及到请求接受任务,任务在运行时,随时都可能变化的是刷怪个数,采集否,是否在某个地图等等条件,如果达到了,立即告诉前端,该任务是可以完成了,

如果玩家点击完成,那么我们将送一些奖励给玩家。做一些任务的变化等等。

这里这是大概的说一些基本的东西,很多东西都是需要慢慢的积累下来了。


背包系统

背包系统算是业务系统中比较复杂的了。为什么这么说呢,因为一旦设计不好,那么将会带来效率的问题。

尤其是在道具的堆叠,整理,移动,拆分,交换等等一些操作。


背包也算是玩家的一个重要数据,因此我们会在玩家内存上开辟一段内存。

背包开发当然一定会涉及到道具,那么道具怎么分呢,目前主要我们主要分成道具和装备两大类。

背包的一个个格子,我们就创建一个结构体,这个结构体里是我们使用共用体表示道具和装备。,基本的相同元素,包括全局唯一id,定义id,等,但是装备是具有其他属性的,比如洗练,打造相关。


当我们想加一个道具到背包中,我们就会提供一个接口,来创建一个格子实例,来判断是否能加入,如果判断可以,就直接加入,否则,告诉玩家失败。


设计协议,我们就要有几下,添加道具,道具移动,道具背包移动和背包的道具移动到仓库。消耗背包中道具,具体协议当然是根据需要变化的数据,保持前后端一直。

这里不在赘述。


地图/副本系统

该系统其实也算是游戏业务逻辑的比较复杂的了。


副本其实也是地图,只不过,副本是相对来说人少的,或者说个人的。对服务器压力来说相对比较小,因为对玩家视野,同屏压力较小。

但是我们主世界地图来说是不一样的,所有人都在主世界地图,我要看到其他人,同屏压力就非常大,对与前端也是很大。

那我们是如何设计呢,首先我们要知道二维地图,都是用小格子来表示,一般小格子都是40像素左右的矩形。我们又提出一个冬天区域有包含多少个字,每个格子都可以有那些玩家。

当一个玩家进入到某点,会进行搜索,把相关数据广播出去。同事,该动态区域也会记住玩家ID。其他玩家同事会看到其他玩家。

玩家身上也会记住事业内的道具的内存ID,玩家内存id,怪物内存id.等等。


副本呢,我们会在通过一个地图定义和一个副本定义表来在副本内存池中申请一段内存,那么在该副本的所有数据进行记录。进行一些玩法加载。

公会系统

公会系统唯一要注意的就是如何实现的权限控制。我么你是使用二维数组来进行权限的的控制。其他的就都是在公会内存中操作了,或者在公会的大类里搞来搞去代码了。


怪物AI。


其实怪ai,涉及到东西比较多了。

比如技能,视野。在整个世界里,每个怪物都要tick一遍,因为怪物所有做的东西都是服务器去控制的。玩家去做打怪,怪物就要做一些反应,比如低级怪就要被打,不去攻击玩家,而高级怪可能要去攻击玩家了。

在攻击的时候,根据怪物技能,攻击玩家,然后通过玩家的一些基本攻防的技能来计算伤害值等等。


在业务系统还有很多东西要描述了。在这里不一一赘述了。如果是C++那么就每个都是一个类,然后对这些类操作。


欢迎访问我的博客,跟多文章访问http://blog.csdn.net/wallwind



作者:wallwind 发表于2013-8-29 23:41:02  原文链接
阅读:330 评论:0  查看评论
 
[转]游戏服务器架构探讨
有段时间没有研究技术了,这次正好看到了新版的mangos,较之以前我看的版本有了比较大的完善,于是再次浏览了下他的代码,也借此机会整理下我在游戏服务器开发方面的一些心得,与大家探讨。 
  另外由于为避免与公司引起一些不必要的纠纷,我所描述的全都是通过google能够找到的资料,所以也可以认为我下面的内容都是网上所找资料的整理合集。在平时的开发中我也搜索过相关的中文网页,很少有讲游戏服务器相关技术的,大家的讨论主要还是集中在3D相关技术,所以也希望我将开始的这几篇文章能够起到抛砖引玉的作用,潜水的兄弟们也都上来透透气。

  要描述一项技术或是一个行业,一般都会从其最古老的历史开始说起,我本也想按着这个套路走,无奈本人乃一八零后小辈,没有经历过那些苦涩的却令人羡慕的单机游戏开发,也没有响当当的拿的出手的优秀作品,所以也就只能就我所了解的一些技术做些简单的描述。一来算是敦促自己对知识做个梳理,二来与大家探讨的过程也能够找到我之前学习的不足和理解上的错误,最后呢,有可能的话也跟业内的同行们混个脸熟,哪天要是想换个工作了也好有个人帮忙介绍下。最后的理由有些俗了。

  关于游戏开发,正如云风在其blog上所说,游戏项目始终只是个小工程,另外开发时间还是个很重要的问题,所以软件工程的思想及方法在大部分的游戏公司中并不怎么受欢迎。当然这也只是从我个人一些肤浅的了解所得,可能不够充分。从游戏开发的程序团队的人员构成上也可看出来,基本只能算作是小开发团队。有些工作室性质的开发团队,那就更简单了。

  我所了解的早些的开发团队,其成员间没有什么严格的分工,大家凭兴趣自由选择一些模块来负责,完成了再去负责另一模块,有其他同事的工作需要接手或协助的也会立即转入。所以游戏开发人员基本都是多面手,从网络到数据库,从游戏逻辑到图形图象,每一项都有所了解,并能实际应用。或者说都具有非常强的学习能力,在接手一项新的任务后能在很短的时间内对该领域的技术迅速掌握并消化,而且还能现炒现卖。当然,这也与早期2D游戏的技术要求相对比较简单,游戏逻辑也没有现在这般复杂有关。而更重要的可能是,都是被逼出来的吧!:)

  好了,闲话少说,下一篇,也就是第一篇了,主题为,服务器结构探讨。


服务器结构探讨 -- 最简单的结构

  所谓服务器结构,也就是如何将服务器各部分合理地安排,以实现最初的功能需求。所以,结构本无所谓正确与错误;当然,优秀的结构更有助于系统的搭建,对系统的可扩展性及可维护性也有更大的帮助。

  好的结构不是一蹴而就的,而且每个设计者心中的那把尺都不相同,所以这个优秀结构的定义也就没有定论。在这里,我们不打算对现有游戏结构做评价,而是试着从头开始搭建一个我们需要的MMOG结构。

  对于一个最简单的游戏服务器来说,它只需要能够接受来自客户端的连接请求,然后处理客户端在游戏世界中的移动及交互,也即游戏逻辑处理即可。如果我们把这两项功能集成到一个服务进程中,则最终的结构很简单:

  client ----- server

  嗯,太简单了点,这样也敢叫服务器结构?好吧,现在我们来往里面稍稍加点东西,让它看起来更像是服务器结构一些。

  一般来说,我们在接入游戏服务器的时候都会要提供一个帐号和密码,验证通过后才能进入。关于为什么要提供用户名和密码才能进入的问题我们这里不打算做过多讨论,云风曾对此也提出过类似的疑问,并给出了只用一个标识串就能进入的设想,有兴趣的可以去看看他们的讨论。但不管是采用何种方式进入,照目前看来我们的服务器起码得提供一个帐号验证的功能。

  我们把观察点先集中在一个大区内。在大多数情况下,一个大区内都会有多组游戏服,也就是多个游戏世界可供选择。简单点来实现,我们完全可以抛弃这个大区的概念,认为一个大区也就是放在同一个机房的多台服务器组,各服务器组间没有什么关系。这样,我们可为每组服务器单独配备一台登录服。最后的结构图应该像这样:

  loginServer  gameServer
     |          /
     |        /
     client

  该结构下的玩家操作流程为,先选择大区,再选择大区下的某台服务器,即某个游戏世界,点击进入后开始帐号验证过程,验证成功则进入了该游戏世界。但是,如果玩家想要切换游戏世界,他只能先退出当前游戏世界,然后进入新的游戏世界重新进行帐号验证。

  早期的游戏大都采用的是这种结构,有些游戏在实现时采用了一些技术手段使得在切换游戏服时不需要再次验证帐号,但整体结构还是未做改变。

  该结构存在一个服务器资源配置的问题。因为登录服处理的逻辑相对来说比较简单,就是将玩家提交的帐号和密码送到数据库进行验证,和生成会话密钥发送给游戏服和客户端,操作完成后连接就会立即断开,而且玩家在以后的游戏过程中不会再与登录服打任何交道。这样处理短连接的过程使得系统在大多数情况下都是比较空闲的,但是在某些时候,由于请求比较密集,比如开新服的时候,登录服的负载又会比较大,甚至会处理不过来。

  另外在实际的游戏运营中,有些游戏世界很火爆,而有些游戏世界却非常冷清,甚至没有多少人玩的情况也是很常见的。所以,我们能否更合理地配置登录服资源,使得整个大区内的登录服可以共享就成了下一步改进的目标。 

服务器结构探讨 -- 登录服的负载均衡 

  回想一下我们在玩wow时的操作流程:运行wow.exe进入游戏后,首先就会要求我们输入用户名和密码进行验证,验证成功后才会出来游戏世界列表,之后是排队进入游戏世界,开始游戏...

  可以看到跟前面的描述有个很明显的不同,那就是要先验证帐号再选择游戏世界。这种结构也就使得登录服不是固定配备给个游戏世界,而是全区共有的。

  我们可以试着从实际需求的角度来考虑一下这个问题。正如我们之前所描述过的那样,登录服在大多数情况下都是比较空闲的,也许我们的一个拥有20个游戏世界的大区仅仅使用10台或更少的登录服即可满足需求。而当在开新区的时候,或许要配备40台登录服才能应付那如潮水般涌入的玩家登录请求。所以,登录服在设计上应该能满足这种动态增删的需求,我们可以在任何时候为大区增加或减少登录服的部署。

  当然,在这里也不会存在要求添加太多登录服的情况。还是拿开新区的情况来说,即使新增加登录服满足了玩家登录的请求,游戏世界服的承载能力依然有限,玩家一样只能在排队系统中等待,或者是进入到游戏世界中导致大家都卡。

  另外,当我们在增加或移除登录服的时候不应该需要对游戏世界服有所改动,也不会要求重启世界服,当然也不应该要求客户端有什么更新或者修改,一切都是在背后自动完成。

  最后,有关数据持久化的问题也在这里考虑一下。一般来说,使用现有的商业数据库系统比自己手工技术先进要明智得多。我们需要持久化的数据有玩家的帐号及密码,玩家创建的角色相关信息,另外还有一些游戏世界全局共有数据也需要持久化。

  好了,需求已经提出来了,现在来考虑如何将其实现。

  对于负载均衡来说,已有了成熟的解决方案。一般最常用,也最简单部署的应该是基于DNS的负载均衡系统了,其通过在DNS中为一个域名配置多个IP地址来实现。最新的DNS服务已实现了根据服务器系统状态来实现的动态负载均衡,也就是实现了真正意义上的负载均衡,这样也就有效地解决了当某台登录服当机后,DNS服务器不能立即做出反应的问题。当然,如果找不到这样的解决方案,自己从头打造一个也并不难。而且,通过DNS来实现的负载均衡已经包含了所做的修改对登录服及客户端的透明。

  而对于数据库的应用,在这种结构下,登录服及游戏世界服都会需要连接数据库。从数据库服务器的部署上来说,可以将帐号和角色数据都放在一个中心数据库中,也可分为两个不同的库分别来处理,基到从物理上分到两台不同的服务器上去也行。

  但是对于不同的游戏世界来说,其角色及游戏内数据都是互相独立的,所以一般情况下也就为每个游戏世界单独配备一台数据库服务器,以减轻数据库的压力。所以,整体的服务器结构应该是一个大区有一台帐号数据库服务器,所有的登录服都连接到这里。而每个游戏世界都有自己的游戏数据库服务器,只允许本游戏世界内的服务器连接。

  最后,我们的服务器结构就像这样:

              大区服务器        
          /     |       / 
            /       |       / 
           登录服1  登录服2   世界服1  世界服2
         /        |        |       |   
          /      |         |        |
          帐号数据库        DBS     DBS

  这里既然讨论到了大区及帐号数据库,所以顺带也说一下关于激活大区的概念。wow中一共有八个大区,我们想要进入某个大区游戏之前,必须到官网上激活这个区,这是为什么呢?

  一般来说,在各个大区帐号数据库之上还有一个总的帐号数据库,我们可以称它为中心数据库。比如我们在官网上注册了一个帐号,这时帐号数据是只保存在中心数据库上的。而当我们要到一区去创建角色开始游戏的时候,在一区的帐号数据库中并没有我们的帐号数据,所以,我们必须先到官网上做一次激活操作。这个激活的过程也就是从中心库上把我们的帐号数据拷贝到所要到的大区帐号数据库中。

服务器结构探讨 -- 简单的世界服实现

  讨论了这么久我们一直都还没有进入游戏世界服务器内部,现在就让我们来窥探一下里面的结构吧。

  对于现在大多数MMORPG来说,游戏服务器要处理的基本逻辑有移动、聊天、技能、物品、任务和生物等,另外还有地图管理与消息广播来对其他高级功能做支撑。如纵队、好友、公会、战场和副本等,这些都是通过基本逻辑功能组合或扩展而成。

  在所有这些基础逻辑中,与我们要讨论的服务器结构关系最紧密的当属地图管理方式。决定了地图的管理方式也就决定了我们的服务器结构,我们仍然先从最简单的实现方式开始说起。

  回想一下我们曾战斗过无数个夜晚的暗黑破坏神,整个暗黑的世界被分为了若干个独立的小地图,当我们在地图间穿越时,一般都要经过一个叫做传送门的装置。世界中有些地图间虽然在地理上是直接相连的,但我们发现其游戏内部的逻辑却是完全隔离的。可以这样认为,一块地图就是一个独立的数据处理单元。

  既然如此,我们就把每块地图都当作是一台独立的服务器,他提供了在这块地图上游戏时的所有逻辑功能,至于内部结构如何划分我们暂不理会,先把他当作一个黑盒子吧。

  当两个人合作做一件事时,我们可以以对等的关系相互协商着来做,而且一般也都不会有什么问题。当人数增加到三个时,我们对等的合作关系可能会有些复杂,因为我们每个人都同时要与另两个人合作协商。正如俗语所说的那样,三个和尚可能会碰到没水喝的情况。当人数继续增加,情况就变得不那么简单了,我们得需要一个管理者来对我们的工作进行分工、协调。游戏的地图服务器之间也是这么回事。

  一般来说,我们的游戏世界不可能会只有一块或者两块小地图,那顺理成章的,也就需要一个地图管理者。先称它为游戏世界的中心服务器吧,毕竟是管理者嘛,大家都以它为中心。

  中心服务器主要维护一张地图ID到地图服务器地址的映射表。当我们要进入某张地图时,会从中心服上取得该地图的IP和port告诉客户端,客户端主动去连接,这样进入他想要去的游戏地图。在整个游戏过程中,客户端始终只会与一台地图服务器保持连接,当要切换地图的时候,在获取到新地图的地址后,会先与当前地图断开连接,再进入新的地图,这样保证玩家数据在服务器上只有一份。

  我们来看看结构图是怎样的:

             中心服务器
          /      /        / 
         /        /        / 
      登录服     地图1    地图2   地图n
         /        |         /       /
          /       |        /       /
               客户端

  很简单,不是吗。但是简单并不表示功能上会有什么损失,简单也更不能表示游戏不能赚钱。早期不少游戏也确实采用的就是这种简单结构。

服务器结构探讨 -- 继续世界服

  都已经看出来了,这种每切换一次地图就要重新连接服务器的方式实在是不够优雅,而且在实际游戏运营中也发现,地图切换导致的卡号,复制装备等问题非常多,这里完全就是一个事故多发地段,如何避免这种频繁的连接操作呢?

  最直接的方法就是把那个图倒转过来就行了。客户端只需要连接到中心服上,所有到地图服务器的数据都由中心服来转发。很完美的解决方案,不是吗?

  这种结构在实际的部署中也遇到了一些挑战。对于一般的MMORPG服务器来说,单台服务器的承载量平均在2000左右,如果你的服务器很不幸地只能带1000人,没关系,不少游戏都是如此;如果你的服务器上跑了3000多玩家依然比较流畅,那你可以自豪地告诉你的策划,多设计些大量消耗服务器资源的玩法吧,比如大型国战、公会战争等。

  2000人,似乎我们的策划朋友们不大愿意接受这个数字。我们将地图服务器分开来原来也是想将负载分开,以多带些客户端,现在要所有的连接都从中心服上转发,那连接数又遇到单台服务器的可最大承载量的瓶颈了。

  这里有必要再解释下这个数字。我知道,有人一定会说,才带2000人,那是你水平不行,我随便写个TCP服务器都可带个五六千连接。问题恰恰在于你是随便写的,而MMORPG的服务器是复杂设计的。如果一个演示socket API用的echo服务器就能满足MMOG服务器的需求,那写服务器该是件多么惬意的事啊。

  但我们所遇到的事实是,服务器收到一个移动包后,要向周围所有人广播,而不是echo服务器那样简单的回应;服务器在收到一个连接断开通知时要向很多人通知玩家退出事件,并将该玩家的资料写入数据库,而不是echo服务器那样什么都不需要做;服务器在收到一个物品使用请求包后要做一系列的逻辑判断以检查玩家有没有作弊;服务器上还启动着很多定时器用来更新游戏世界的各种状态......

  其实这么一比较,我们也看出资源消耗的所在了:服务器上大量的复杂的逻辑处理。再回过头来看看我们想要实现的结构,我们既想要有一个唯一的入口,使得客户端不用频繁改变连接,又希望这个唯一入口的负载不会太大,以致于接受不了多少连接。

  仔细看一看这个需求,我们想要的仅仅只是一台管理连接的服务器,并不打算让他承担太多的游戏逻辑。既然如此,那五六千个连接也还有满足我们的要求。至少在现在来说,一个游戏世界内,也就是一组服务器内同时有五六千个在线的玩家还是件让人很兴奋的事。事实上,在大多数游戏的大部分时间里,这个数字也是很让人眼红的。

  什么?你说梦幻、魔兽还有史先生的那个什么征途远不止这么点人了!噢,我说的是大多数,是大多数,不包括那些明星。你知道大陆现在有多少游戏在运营吗?或许你又该说,我们不该在一开始就把自己的目标定的太低!好吧,我们还是先不谈这个。

  继续我们的结构讨论。一般来说,我们把这台负责连接管理的服务器称为网关服务器,因为内部的数据都要通过这个网关才能出去,不过从这台服务器提供的功能来看,称其为反向代理服务器可能更合适。我们也不在这个名字上纠缠了,就按大家通用的叫法,还是称他为网关服务器吧。

  网关之后的结构我们依然可以采用之前描述的方案,只是,似乎并没有必要为每一个地图都开一个独立的监听端口了。我们可以试着对地图进行一些划分,由一个Master Server来管理一些更小的Zone Server,玩家通过网关连接到Master Server上,而实际与地图有关的逻辑是分派给更小的Zone Server去处理。

  最后的结构看起来大概是这样的:

        Zone Server        Zone Server
                /            /
                 /          /
                Master Server          Master Server
                    /       /                   /
                   /         /                 /
        Gateway Server        /               /
            |        /         /             /
            |         /         /           /
            |               Center Server
            |
            |
           Client 

服务器结构探讨 -- 最终的结构

  如果我们就此打住,可能马上就会有人要嗤之以鼻了,就这点古董级的技术也敢出来现。好吧,我们还是把之前留下的问题拿出来解决掉吧。

  一般来说,当某一部分能力达不到我们的要求时,最简单的解决方法就是在此多投入一点资源。既然想要更多的连接数,那就再加一台网关服务器吧。新增加了网关服后需要在大区服上做相应的支持,或者再简单点,有一台主要的网关服,当其负载较高时,主动将新到达的连接重定向到其他网关服上。

  而对于游戏服来说,有一台还是多台网关服是没有什么区别的。每个代表客户端玩家的对象内部都保留一个代表其连接的对象,消息广播时要求每个玩家对象使用自己的连接对象发送数据即可,至于连接是在什么地方,那是完全透明的。当然,这只是一种简单的实现,也是普通使用的一种方案,如果后期想对消息广播做一些优化的话,那可能才需要多考虑一下。

  既然说到了优化,我们也稍稍考虑一下现在结构下可能采用的优化方案。

  首先是当前的Zone Server要做的事情太多了,以至于他都处理不了多少连接。这其中最消耗系统资源的当属生物的AI处理了,尤其是那些复杂的寻路算法,所以我们可以考虑把这部分AI逻辑独立出来,由一台单独的AI服务器来承担。

  然后,我们可以试着把一些与地图数据无关的公共逻辑放到Master Server上去实现,这样Zone Server上只保留了与地图数据紧密相关的逻辑,如生物管理,玩家移动和状态更新等。

  还有聊天处理逻辑,这部分与游戏逻辑没有任何关联,我们也完全可以将其独立出来,放到一台单独的聊天服务器上去实现。

  最后是数据库了,为了减轻数据库的压力,提高数据请求的响应速度,我们可以在数据库之前建立一个数据库缓存服务器,将一些常用数据缓存在此,服务器与数据库的通信都要通过这台服务器进行代理。缓存的数据会定时的写入到后台数据库中。

  好了,做完这些优化我们的服务器结构大体也就定的差不多了,暂且也不再继续深入,更细化的内容等到各个部分实现的时候再探讨。

  好比我们去看一场晚会,舞台上演员们按着预定的节目单有序地上演着,但这就是整场晚会的全部吗?显然不止,在幕后还有太多太多的人在忙碌着,甚至在晚会前和晚会后都有。我们的游戏服务器也如此。

  在之前描述的部分就如同舞台上的演员,是我们能直接看到的,幕后的工作人员我们也来认识一下。

  现实中有警察来维护秩序,游戏中也如此,这就是我们常说的GM。GM可以采用跟普通玩家一样的拉入方式来进入游戏,当然权限会比普通玩家高一些,也可以提供一台GM服务器专门用来处理GM命令,这样可以有更高的安全性,GM服一般接在中心服务器上。

  在以时间收费的游戏中,我们还需要一台计费的服务器,这台服务器一般接在网关服务器上,注册玩家登录和退出事件以记录玩家的游戏时间。

  任何为用户提供服务的地方都会有日志记录,游戏服务器当然也不例外。从记录玩家登录的时间,地址,机器信息到游戏过程中的每一项操作都可以作为日志记录下来,以备查错及数据挖掘用。至于搜集玩家机器资料所涉及到的法律问题不是我们该考虑的。

  差不多就这么多了吧,接下来我们会按照这个大致的结构来详细讨论各部分的实现。

服务器结构探讨 -- 一点杂谈

  再强调一下,服务器结构本无所谓好坏,只有是否适合自己。我们在前面探讨了一些在现在的游戏中见到过的结构,并尽我所知地分析了各自存在的一些问题和可以做的一些改进,希望其中没有谬误,如果能给大家也带来些启发那自然更好。

  突然发现自己一旦罗嗦起来还真是没完没了。接下来先说说我在开发中遇到过的一些困惑和一基础问题探讨吧,这些问题可能有人与我一样,也曾遇到过,或者正在被困扰中,而所要探讨的这些基础问题向来也是争论比较多的,我们也不评价其中的好与坏,只做简单的描述。

  首先是服务器操作系统,linux与windows之争随处可见,其实在大多数情况下这不是我们所能决定的,似乎各大公司也基本都有了自己的传统,如网易的freebsd,腾讯的linux等。如果真有权利去选择的话,选自己最熟悉的吧。

  决定了OS也就基本上确定了网络IO模型,windows上的IOCP和linux下的epool,或者直接使用现有的网络框架,如ACE和asio等,其他还有些商业的网络库在国内的使用好像没有见到,不符合中国国情嘛。:)

  然后是网络协议的选择,以前的选择大多倾向于UDP,为了可靠传输一般自己都会在上面实现一层封装,而现在更普通的是直接采用本身就很可靠的TCP,或者TCP与UDP的混用。早期选择UDP的主要原因还是带宽限制,现在宽带普通的情况下TCP比UDP多出来的一点点开销与开发的便利性相比已经不算什么了。当然,如果已有了成熟的可靠UDP库,那也可以继续使用着。

  还有消息包格式的定义,这个曾在云风的blog上展开过激烈的争论。消息包格式定义包括三段,包长、消息码和包体,争论的焦点在于应该是消息码在前还是包长在前,我们也把这个当作是信仰问题吧,有兴趣的去云风的blog上看看,论论。

  另外早期有些游戏的包格式定义是以特殊字符作分隔的,这样一个好处是其中某个包出现错误后我们的游戏还能继续。但实际上,我觉得这是完全没有必要的,真要出现这样的错误,直接断开这个客户端的连接可能更安全。而且,以特殊字符做分隔的消息包定义还加大了一点点网络数据量。

  最后是一个纯技术问题,有关socket连接数的最大限制。开始学习网络编程的时候我犯过这样的错误,以为port的定义为unsigned short,所以想当然的认为服务器的最大连接数为65535,这会是一个硬性的限制。而实际上,一个socket描述符在windows上的定义是unsigned int,因此要有限制那也是四十多亿,放心好了。

  在服务器上port是监听用的,想象这样一种情况,web server在80端口上监听,当一个连接到来时,系统会为这个连接分配一个socket句柄,同时与其在80端口上进行通讯;当另一个连接到来时,服务器仍然在80端口与之通信,只是分配的socket句柄不一样。这个socket句柄才是描述每个连接的唯一标识。按windows网络编程第二版上的说法,这个上限值配置影响。

  好了,废话说完了,下一篇,我们开始进入登录服的设计吧。

登录服的设计 -- 功能需求

  正如我们在前面曾讨论过的,登录服要实现的功能相当简单,就是帐号验证。为了便于描述,我们暂不引入那些讨论过的优化手段,先以最简单的方式实现,另外也将基本以mangos的代码作为参考来进行描述。

  想象一下帐号验证的实现方法,最容易的那就是把用户输入的明文用帐号和密码直接发给登录服,服务器根据帐号从数据库中取出密码,与用户输入的密码相比较。

  这个方法存在的安全隐患实在太大,明文的密码传输太容易被截获了。那我们试着在传输之前先加一下密,为了服务器能进行密码比较,我们应该采用一个可逆的加密算法,在服务器端把这个加密后的字串还原为原始的明文密码,然后与数据库密码进行比较。既然是一个可逆的过程,那外挂制作者总有办法知道我们的加密过程,所以,这个方法仍不够安全。

  哦,如果我们只是希望密码不可能被还原出来,那还不容易吗,使用一个不可逆的散列算法就行了。用户在登录时发送给服务器的是明文的帐号和经散列后的不可逆密码串,服务器取出密码后也用同样的算法进行散列后再进行比较。比如,我们就用使用最广泛的md5算法吧。噢,不要管那个王小云的什么论文,如果我真有那么好的运气,早中500w了,还用在这考虑该死的服务器设计吗?

  似乎是一个很完美的方案,外挂制作者再也偷不到我们的密码了。慢着,外挂偷密码的目的是什么?是为了能用我们的帐号进游戏!如果我们总是用一种固定的算法来对密码做散列,那外挂只需要记住这个散列后的字串就行了,用这个做密码就可以成功登录。

  嗯,这个问题好解决,我们不要用固定的算法进行散列就是了。只是,问题在于服务器与客户端采用的散列算法得出的字串必须是相同的,或者是可验证其是否匹配的。很幸运的是,伟大的数学字们早就为我们准备好了很多优秀的这类算法,而且经理论和实践都证明他们也确实是足够安全的。

  这其中之一是一个叫做SRP的算法,全称叫做Secure Remote Password,即安全远程密码。wow使用的是第6版,也就是SRP6算法。有关其中的数学证明,如果有人能向我解释清楚,并能让我真正弄明白的话,我将非常感激。不过其代码实现步骤倒是并不复杂,mangos中的代码也还算清晰,我们也不再赘述。

  登录服除了帐号验证外还得提供另一项功能,就是在玩家的帐号验证成功后返回给他一个服务器列表让他去选择。这个列表的状态要定时刷新,可能有新的游戏世界开放了,也可能有些游戏世界非常不幸地停止运转了,这些状态的变化都要尽可能及时地让玩家知道。不管发生了什么事,用户都有权利知道,特别是对于付过费的用户来说,我们不该藏着掖着,不是吗?

  这个游戏世界列表的功能将由大区服来提供,具体的结构我们在之前也描述过,这里暂不做讨论。登录服将从大区服上获取到的游戏世界列表发给已验证通过的客户端即可。好了,登录服要实现的功能就这些,很简单,是吧。

  确实是太简单了,不过简单的结构正好更适合我们来看一看游戏服务器内部的模块结构,以及一些服务器共有组件的实现方法。这就留作下一篇吧。

服务器公共组件实现 -- mangos的游戏主循环

  当阅读一项工程的源码时,我们大概会选择从main函数开始,而当开始一项新的工程时,第一个写下的函数大多也是main。那我们就先来看看,游戏服务器代码实现中,main函数都做了些什么。

  由于我在读技术文章时最不喜看到的就是大段大段的代码,特别是那些直接Ctrl+C再Ctrl+V后未做任何修改的代码,用句时髦的话说,一点技术含量都没有!所以在我们今后所要讨论的内容中,尽量会避免出现直接的代码,在有些地方确实需要代码来表述时,也将会选择使用伪码。

  先从mangos的登录服代码开始。mangos的登录服是一个单线程的结构,虽然在数据库连接中可以开启一个独立的线程,但这个线程也只是对无返回结果的执行类SQL做缓冲,而对需要有返回结果的查询类SQL还是在主逻辑线程中阻塞调用的。

  登录服中唯一的这一个线程,也就是主循环线程对监听的socket做select操作,为每个连接进来的客户端读取其上的数据并立即进行处理,直到服务器收到SIGABRT或SIGBREAK信号时结束。

  所以,mangos登录服主循环的逻辑,也包括后面游戏服的逻辑,主循环的关键代码其实是在SocketHandler中,也就是那个Select函数中。检查所有的连接,对新到来的连接调用OnAccept方法,有数据到来的连接则调用OnRead方法,然后socket处理器自己定义对接收到的数据如何处理。

  很简单的结构,也比较容易理解。


  只是,在对性能要求比较高的服务器上,select一般不会是最好的选择。如果我们使用windows平台,那IOCP将是首选;如果是linux,epool将是不二选择。我们也不打算讨论基于IOCP或是基于epool的服务器实现,如果仅仅只是要实现服务器功能,很简单的几个API调用即可,而且网上已有很多好的教程;如果是要做一个成熟的网络服务器产品,不是我几篇简单的技术介绍文章所能达到。

  另外,在服务器实现上,网络IO与逻辑处理一般会放在不同的线程中,以免耗时较长的IO过程阻塞住了需要立即反应的游戏逻辑。

  数据库的处理也类似,会使用异步的方式,也是避免耗时的查询过程将游戏服务器主循环阻塞住。想象一下,因某个玩家上线而发起的一次数据库查询操作导致服务器内所有在线玩家都卡住不动将是多么恐怖的一件事!

  另外还有一些如事件、脚本、消息队列、状态机、日志和异常处理等公共组件,我们也会在接下来的时间里进行探讨。

服务器公共组件实现 -- 继续来说主循环

  前面我们只简单了解了下mangos登录服的程序结构,也发现了一些不足之处,现在我们就来看看如何提供一个更好的方案。

  正如我们曾讨论过的,为了游戏主逻辑循环的流畅运行,所有比较耗时的IO操作都会分享到单独的线程中去做,如网络IO,数据库IO和日志IO等。当然,也有把这些分享到单独的进程中去做的。

  另外对于大多数服务器程序来说,在运行时都是作为精灵进程或服务进程的,所以我们并不需要服务器能够处理控制台用户输入,我们所要处理的数据来源都来自网络。

  这样,主逻辑循环所要做的就是不停要取消息包来处理,当然这些消息包不仅有来自客户端的玩家操作数据包,也有来自GM服务器的管理命令,还包括来自数据库查询线程的返回结果消息包。这个循环将一直持续,直到收到一个通知服务器关闭的消息包。

  主逻辑循环的结构还是很简单的,复杂的部分都在如何处理这些消息包的逻辑上。我们可以用一段简单的伪码来描述这个循环过程:

    while (Message* msg = getMessage())
    {
      if (msg为服务器关闭消息)
        break;
      处理msg消息;
    }

  这里就有一个问题需要探讨了,在getMessage()的时候,我们应该去哪里取消息?前面我们考虑过,至少会有三个消息来源,而我们还讨论过,这些消息源的IO操作都是在独立的线程中进行的,我们这里的主线程不应该直接去那几处消息源进行阻塞式的IO操作。

  很简单,让那些独立的IO线程在接收完数据后自己送过来就是了。好比是,我这里提供了一个仓库,有很多的供货商,他们有货要给我的时候只需要交到仓库,然后我再到仓库去取就是了,这个仓库也就是消息队列。消息队列是一个普通的队列实现,当然必须要提供多线程互斥访问的安全性支持,其基本的接口定义大概类似这样:

    IMessageQueue
    {
      void putMessage(Message*);
      Message* getMessage();
    }

  网络IO,数据库IO线程把整理好的消息包都加入到主逻辑循环线程的这个消息队列中便返回。有关消息队列的实现和线程间消息的传递在ACE中有比较完全的代码实现及描述,还有一些使用示例,是个很好的参考。

  这样的话,我们的主循环就很清晰了,从主线程的消息队列中取消息,处理消息,再取下一条消息......

服务器公共组件实现 -- 消息队列

  既然说到了消息队列,那我们继续来稍微多聊一点吧。

  我们所能想到的最简单的消息队列可能就是使用stl的list来实现了,即消息队列内部维护一个list和一个互斥锁,putMessage时将message加入到队列尾,getMessage时从队列头取一个message返回,同时在getMessage和putMessage之前都要求先获取锁资源。

  实现虽然简单,但功能是绝对满足需求的,只是性能上可能稍稍有些不尽如人意。其最大的问题在频繁的锁竞争上。

  对于如何减少锁竞争次数的优化方案,Ghost Cheng提出了一种。提供一个队列容器,里面有多个队列,每个队列都可固定存放一定数量的消息。网络IO线程要给逻辑线程投递消息时,会从队列容器中取一个空队列来使用,直到将该队列填满后再放回容器中换另一个空队列。而逻辑线程取消息时是从队列容器中取一个有消息的队列来读取,处理完后清空队列再放回到容器中。

  这样便使得只有在对队列容器进行操作时才需要加锁,而IO线程和逻辑线程在操作自己当前使用的队列时都不需要加锁,所以锁竞争的机会大大减少了。

  这里为每个队列设了个最大消息数,看来好像是打算只有当IO线程写满队列时才会将其放回到容器中换另一个队列。那这样有时也会出现IO线程未写满一个队列,而逻辑线程又没有数据可处理的情况,特别是当数据量很少时可能会很容易出现。Ghost Cheng在他的描述中没有讲到如何解决这种问题,但我们可以先来看看另一个方案。

  这个方案与上一个方案基本类似,只是不再提供队列容器,因为在这个方案中只使用了两个队列,arthur在他的一封邮件中描述了这个方案的实现及部分代码。两个队列,一个给逻辑线程读,一个给IO线程用来写,当逻辑线程读完队列后会将自己的队列与IO线程的队列相调换。所以,这种方案下加锁的次数会比较多一些,IO线程每次写队列时都要加锁,逻辑线程在调换队列时也需要加锁,但逻辑线程在读队列时是不需要加锁的。

  虽然看起来锁的调用次数是比前一种方案要多很多,但实际上大部分锁调用都是不会引起阻塞的,只有在逻辑线程调换队列的那一瞬间可能会使得某个线程阻塞一下。另外对于锁调用过程本身来说,其开销是完全可以忽略的,我们所不能忍受的仅仅是因为锁调用而引起的阻塞而已。

  两种方案都是很优秀的优化方案,但也都是有其适用范围的。Ghost Cheng的方案因为提供了多个队列,可以使得多个IO线程可以总工程师的,互不干扰的使用自己的队列,只是还有一个遗留问题我们还不了解其解决方法。arthur的方案很好的解决了上一个方案遗留的问题,但因为只有一个写队列,所以当想要提供多个IO线程时,线程间互斥地写入数据可能会增大竞争的机会,当然,如果只有一个IO线程那将是非常完美的。

服务器公共组件实现 -- 环形缓冲区

  消息队列锁调用太频繁的问题算是解决了,另一个让人有些苦恼的大概是这太多的内存分配和释放操作了。频繁的内存分配不但增加了系统开销,更使得内存碎片不断增多,非常不利于我们的服务器长期稳定运行。也许我们可以使用内存池,比如SGI STL中附带的小内存分配器。但是对于这种按照严格的先进先出顺序处理的,块大小并不算小的,而且块大小也并不统一的内存分配情况来说,更多使用的是一种叫做环形缓冲区的方案,mangos的网络代码中也有这么一个东西,其原理也是比较简单的。

  就好比两个人围着一张圆形的桌子在追逐,跑的人被网络IO线程所控制,当写入数据时,这个人就往前跑;追的人就是逻辑线程,会一直往前追直到追上跑的人。如果追上了怎么办?那就是没有数据可读了,先等会儿呗,等跑的人向前跑几步了再追,总不能让游戏没得玩了吧。那要是追的人跑的太慢,跑的人转了一圈过来反追上追的人了呢?那您也先歇会儿吧。要是一直这么反着追,估计您就只能换一个跑的更快的追逐者了,要不这游戏还真没法玩下去。

  前面我们特别强调了,按照严格的先进先出顺序进行处理,这是环形缓冲区的使用必须遵守的一项要求。也就是,大家都得遵守规定,追的人不能从桌子上跨过去,跑的人当然也不允许反过来跑。至于为什么,不需要多做解释了吧。

  环形缓冲区是一项很好的技术,不用频繁的分配内存,而且在大多数情况下,内存的反复使用也使得我们能用更少的内存块做更多的事。

  在网络IO线程中,我们会为每一个连接都准备一个环形缓冲区,用于临时存放接收到的数据,以应付半包及粘包的情况。在解包及解密完成后,我们会将这个数据包复制到逻辑线程消息队列中,如果我们只使用一个队列,那这里也将会是个环形缓冲区,IO线程往里写,逻辑线程在后面读,互相追逐。可要是我们使用了前面介绍的优化方案后,可能这里便不再需要环形缓冲区了,至少我们并不再需要他们是环形的了。因为我们对同一个队列不再会出现同时读和写的情况,每个队列在写满后交给逻辑线程去读,逻辑线程读完后清空队列再交给IO线程去写,一段固定大小的缓冲区即可。没关系,这么好的技术,在别的地方一定也会用到的。

服务器公共组件实现 -- 发包的方式

  前面一直都在说接收数据时的处理方法,我们应该用专门的IO线程,接收到完整的消息包后加入到主线程的消息队列,但是主线程如何发送数据还没有探讨过。

  一般来说最直接的方法就是逻辑线程什么时候想发数据了就直接调用相关的socket API发送,这要求服务器的玩家对象中保存其连接的socket句柄。但是直接send调用有时候有会存在一些问题,比如遇到系统的发送缓冲区满而阻塞住的情况,或者只发送了一部分数据的情况也时有发生。我们可以将要发送的数据先缓存一下,这样遇到未发送完的,在逻辑线程的下一次处理时可以接着再发送。

  考虑数据缓存的话,那这里这可以有两种实现方式了,一是为每个玩家准备一个缓冲区,另外就是只有一个全局的缓冲区,要发送的数据加入到全局缓冲区的时候同时要指明这个数据是发到哪个socket的。如果使用全局缓冲区的话,那我们可以再进一步,使用一个独立的线程来处理数据发送,类似于逻辑线程对数据的处理方式,这个独立发送线程也维护一个消息队列,逻辑线程要发数据时也只是把数据加入到这个队列中,发送线程循环取包来执行send调用,这时的阻塞也就不会对逻辑线程有任何影响了。

  采用第二种方式还可以附带一个优化方案。一般对于广播消息而言,发送给周围玩家的数据都是完全相同的,我们如果采用给每个玩家一个缓冲队列的方式,这个数据包将需要拷贝多份,而采用一个全局发送队列时,我们只需要把这个消息入队一次,同时指明该消息包是要发送给哪些socket的即可。有关该优化的说明在云风描述其连接服务器实现的blog文章中也有讲到,有兴趣的可以去阅读一下。

服务器公共组件实现 -- 状态机

  有关State模式的设计意图及实现就不从设计模式中摘抄了,我们只来看看游戏服务器编程中如何使用State设计模式。

  首先还是从mangos的代码开始看起,我们注意到登录服在处理客户端发来的消息时用到了这样一个结构体:

  struct AuthHandler
  {
    eAuthCmd cmd;
    uint32 status;
    bool (AuthSocket::*handler)(void);
  };

  该结构体定义了每个消息码的处理函数及需要的状态标识,只有当前状态满足要求时才会调用指定的处理函数,否则这个消息码的出现是不合法的。这个status状态标识的定义是一个宏,有两种有效的标识,STATUS_CONNECTED和STATUS_AUTHED,也就是未认证通过和已认证通过。而这个状态标识的改变是在运行时进行的,确切的说是在收到某个消息并正确处理完后改变的。

  我们再来看看设计模式中对State模式的说明,其中关于State模式适用情况里有一条,当操作中含有庞大的多分支的条件语句,且这些分支依赖于该对象的状态,这个状态通常用一个或多个枚举变量表示。

  描述的情况与我们这里所要处理的情况是如此的相似,也许我们可以试一试。那再看看State模式提供的解决方案是怎样的,State模式将每一个条件分支放入一个独立的类中。

  由于这里的两个状态标识只区分出了两种状态,所以,我们仅需要两个独立的类,用以表示两种状态即可。然后,按照State模式的描述,我们还需要一个Context类,也就是状态机管理类,用以管理当前的状态类。稍作整理,大概的代码会类似这样:

  状态基类接口:
  StateBase
  {
    void Enter() = 0;
    void Leave() = 0;
    void Process(Message* msg) = 0;
  };

  状态机基类接口:
  MachineBase
  {
    void ChangeState(StateBase* state) = 0;

    StateBase* m_curState;
  };

  我们的逻辑处理类会从MachineBase派生,当取出数据包后交给当前状态处理,前面描述的两个状态类从StateBase派生,每个状态类只处理该状态标识下需要处理的消息。当要进行状态转换时,调用MachineBase的ChangeState()方法,显示地告诉状态机管理类自己要转到哪一个状态。所以,状态类内部需要保存状态机管理类的指针,这个可以在状态类初始化时传入。具体的实现细节就不做过多描述了。

  使用状态机虽然避免了复杂的判断语句,但也引入了新的麻烦。当我们在进行状态转换时,可能会需要将一些现场数据从老状态对象转移到新状态对象,这需要在定义接口时做一下考虑。如果不希望执行拷贝,那么这里公有的现场数据也可放到状态机类中,只是这样在使用时可能就不那么优雅了。

  正如同在设计模式中所描述的,所有的模式都是已有问题的另一种解决方案,也就是说这并不是唯一的解决方案。放到我们今天讨论的State模式中,就拿登录服所处理的两个状态来说,也许用mangos所采用的遍历处理函数的方法可能更简单,但当系统中的状态数量增多,状态标识也变多的时候,State模式就显得尤其重要了。

  比如在游戏服务器上玩家的状态管理,还有在实现NPC人工智能时的各种状态管理,这些就留作以后的专题吧。

服务器公共组件 -- 事件与信号

  关于这一节,这几天已经打了好几遍草稿,总觉得说不清楚,也不好组织这些内容,但是打铁要趁热,为避免热情消退,先整理一点东西放这,好继续下面的主题,以后如果有机会再回来完善吧。本节内容欠考虑,希望大家多给点意见。

  有些类似于QT中的event与signal,我将一些动作请求消息定义为事件,而将状态改变消息定义为信号。比如在QT应用程序中,用户的一次鼠标点击会产生一个鼠标点击事件加入到事件队列中,当处理此事件时可能会导致某个按钮控件产生一个clicked()信号。

  对应到我们的服务器上的一个例子,玩家登录时会发给服务器一个请求登录的数据包,服务器可将其当作一个用户登录事件,该事件处理完后可能会产生一个用户已登录信号。

  这样,与QT类似,对于事件我们可以重定义其处理方法,甚至过滤掉某些事件使其不被处理,但对于信号我们只是收到了一个通知,有些类似于Observe模式中的观察者,当收到更新通知时,我们只能更新自己的状态,对刚刚发生的事件我不已不能做任何影响。

  仔细来看,事件与信号其实并无多大差别,从我们对其需求上来说,都只要能注册事件或信号响应函数,在事件或信号产生时能够被通知到即可。但有一项区别在于,事件处理函数的返回值是有意义的,我们要根据这个返回值来确定是否还要继续事件的处理,比如在QT中,事件处理函数如果返回true,则这个事件处理已完成,QApplication会接着处理下一个事件,而如果返回false,那么事件分派函数会继续向上寻找下一个可以处理该事件的注册方法。信号处理函数的返回值对信号分派器来说是无意义的。

  简单点说,就是我们可以为事件定义过滤器,使得事件可以被过滤。这一功能需求在游戏服务器上是到处存在的。

  关于事件和信号机制的实现,网络上的开源训也比较多,比如FastDelegate,sigslot,boost::signal等,其中sigslot还被Google采用,在libjingle的代码中我们可以看到他是如何被使用的。

  在实现事件和信号机制时或许可以考虑用同一套实现,在前面我们就分析过,两者唯一的区别仅在于返回值的处理上。

  另外还有一个需要我们关注的问题是事件和信号处理时的优先级问题。在QT中,事件因为都是与窗口相关的,所以事件回调时都是从当前窗口开始,一级一级向上派发,直到有一个窗口返回true,截断了事件的处理为止。对于信号的处理则比较简单,默认是没有顺序的,如果需要明确的顺序,可以在信号注册时显示地指明槽的位置。

  在我们的需求中,因为没有窗口的概念,事件的处理也与信号类似,对注册过的处理器要按某个顺序依次回调,所以优先级的设置功能是需要的。

  最后需要我们考虑的是事件和信号的处理方式。在QT中,事件使用了一个事件队列来维护,如果事件的处理中又产生了新的事件,那么新的事件会加入到队列尾,直到当前事件处理完毕后,QApplication再去队列头取下一个事件来处理。而信号的处理方式有些不同,信号处理是立即回调的,也就是一个信号产生后,他上面所注册的所有槽都会立即被回调。这样就会产生一个递归调用的问题,比如某个信号处理器中又产生了一个信号,会使得信号的处理像一棵树一样的展开。我们需要注意的一个很重要的问题是会不会引起循环调用。

  关于事件机制的考虑其实还很多,但都是一些不成熟的想法。在上面的文字中就同时出现了消息、事件和信号三个相近的概念,而在实际处理中,经常发现三者不知道如何界定的情况,实际的情况比我在这里描述的要混乱的多。

  这里也就当是挖下一个坑,希望能够有所交流。

再谈登录服的实现

    离我们的登录服实现已经太远了,先拉回来一下。
    
    关于登录服、大区服及游戏世界服的结构之前已做过探讨,这里再把各自的职责和关系列一下。

        GateWay/WorldServer   GateWay/WodlServer  LoginServer LoginServer DNSServer WorldServerMgr
                |                     |                     |                 |            |
      ---------------------------------------------------------------------------------------------
                                             |  |  |
                                             internet
                                                |
                                              clients

    其中DNSServer负责带负载均衡的域名解析服务,返回LoginServer的IP地址给客户端。WorldServerMgr维护当前大区内的世界服列表,LoginServer会从这里取世界列表发给客户端。LoginServer处理玩家的登录及世界服选择请求。GateWay/WorldServer为各个独立的世界服或者通过网关连接到后面的世界服。

    在mangos的代码中,我们注意到登录服是从数据库中取的世界列表,而在wow官方服务器中,我们却会注意到,这个世界服列表并不是一开始就固定,而是动态生成的。当每周一次的维护完成之后,我们可以很明显的看到这个列表生成的过程。刚开始时,世界列表是空的,慢慢的,世界服会一个个加入进来,而这里如果有世界服当机,他会显示为离线,不会从列表中删除。但是当下一次服务器再维护后,所有的世界服都不存在了,全部重新开始添加。

    从上面的过程描述中,我们很容易想到利用一个临时的列表来保存世界服信息,这也是我们增加WorldServerMgr服务器的目的所在。GateWay/WorldServer在启动时会自动向WorldServerMgr注册自己,这样就把自己所代表的游戏世界添加到世界列表中了。类似的,如果DNSServer也可以让LoginServer自己去注册,这样在临时LoginServer时就不需要去改动DNSServer的配置文件了。

    WorldServerMgr内部的实现很简单,监听一个固定的端口,接受来自WorldServer的主动连接,并检测其状态。这里可以用一个心跳包来实现其状态的检测,如果WorldServer的连接断开或者在规定时间内未收到心跳包,则将其状态更新为离线。另外WorldServerMgr还处理来自LoginServer的列表请求。由于世界列表并不常变化,所以LoginServer没有必要每次发送世界列表时都到WorldServerMgr上去取,LoginServer完全可以自己维护一个列表,当WorldServerMgr上的列表发生变化时,WorldServerMgr会主动通知所有的LoginServer也更新一下自己的列表。这个或许就可以用前面描述过的事件方式,或者就是观察者模式了。

    WorldServerMgr实现所要考虑的内容就这些,我们再来看看LoginServer,这才是我们今天要重点讨论的对象。

    前面探讨一些服务器公共组件,那我们这里也应该试用一下,不能只是停留在理论上。先从状态机开始,前面也说过了,登录服上的连接会有两种状态,一是帐号密码验证状态,一是服务器列表选择状态,其实还有另外一个状态我们未曾讨论过,因为它与我们的登录过程并无多大关系,这就是升级包发送状态。三个状态的转换流程大致为:

        LogonState -- 验证成功 -- 版本检查 -- 版本低于最新值 -- 转到UpdateState
                                          |
                                           -- 版本等于最新值 -- 转到WorldState

    这个版本检查的和决定下一个状态的过程是在LogonState中进行的,下一个状态的选择是由当前状态来决定。密码验证的过程使用了SRP6协议,具体过程就不多做描述,每个游戏使用的方式也都不大一样。而版本检查的过程就更无值得探讨的东西,一个if-else即可。

    升级状态其实就是文件传输过程,文件发送完毕后通知客户端开始执行升级文件并关闭连接。世界选择状态则提供了一个列表给客户端,其中包括了所有游戏世界网关服务器的IP、PORT和当前负载情况。如果客户端一直连接着,则该状态会以每5秒一次的频率不停刷新列表给客户端,当然是否值得这样做还是有待商榷。

    整个过程似乎都没有值得探讨的内容,但是,还没有完。当客户端选择了一个世界之后该怎么办?wow的做法是,当客户端选择一个游戏世界时,客户端会主动去连接该世界服的IP和PORT,然后进入这个游戏世界。与此同时,与登录服的连接还没有断开,直到客户端确实连接上了选定的世界服并且走完了排队过程为止。这是一个很必要的设计,保证了我们在因意外情况连接不上世界服或者发现世界服正在排队而想换另外一个试试时不会需要重新进行密码验证。

    但是我们所要关注的还不是这些,而是客户端去连接游戏世界的网关服时服务器该如何识别我们。打个比方,有个不自觉的玩家不遵守游戏规则,没有去验证帐号密码就直接跑去连接世界服了,就如同一个不自觉的乘客没有换登机牌就直接跑到登机口一样。这时,乘务员会客气地告诉你要先换登机牌,那登机牌又从哪来?检票口换的,人家会先验明你的身份,确认后才会发给你登机牌。一样的处理过程,我们的登录服在验明客户端身份后,也会发给客户端一个登机牌,这个登机牌还有一个学名,叫做session key。

    客户端拿着这个session key去世界服网关处就可正确登录了吗?似乎还是有个疑问,他怎么知道我这个key是不是造假的?没办法,中国的假货太多,我们不得不到处都考虑假货的问题。方法很简单,去找给他登机牌的那个检票员问一下,这张牌是不是他发的不就得了。可是,那么多的LoginServer,要一个个问下来,这效率也太低了,后面排的长队一定会开始叫唤了。那么,LoginServer将这个key存到数据库中,让网关服自己去数据库验证?似乎也是个可行的方案。

    如果觉得这样给数据库带来了太大的压力的话,也可以考虑类似WorldServerMgr的做法,用一个临时的列表来保存,甚至可以将这个列表就保存到WorldServerMgr上,他正好是全区唯一的。这两种方案的本质并无差别,只是看你愿意将负载放在哪里。而不管在哪里,这个查询的压力都是有点大的,想想,全区所有玩家呢。所以,我们也可以试着考虑一种新的方案,一种不需要去全区唯一一个入口查询的方案。

    那我们将这些session key分开存储不就得了。一个可行的方案是,让任意时刻只有一个地方保存一个客户端的session key,这个地方可能是客户端当前正连接着的服务器,也可以是它正要去连接的服务器。让我们来详细描述一下这个过程,客户端在LoginServer上验证通过时,LoginServer为其生成了本次会话的session key,但只是保存在当前的LoginServer上,不会存数据库,也不会发送给WorldServerMgr。如果客户端这时想要去某个游戏世界,那么他必须先通知当前连接的LoginServer要去的服务器地址,LoginServer将session key安全转移给目标服务器,转移的意思是要确保目标服务器收到了session key,本地保存的要删除掉。转移成功后LoginServer通知客户端再去连接目标服务器,这时目标服务器在验证session key合法性的时候就不需要去别处查询了,只在本地保存的session key列表中查询即可。

    当然了,为了session key的安全,所有的服务器在收到一个新的session key后都会为其设一个有效期,在有效期过后还没来认证的,则该session key会被自动删除。同时,所有服务器上的session key在连接关闭后一定会被删除,保证一个session key真正只为一次连接会话服务。

    但是,很显然的,wow并没有采用这种方案,因为客户端在选择世界服时并没有向服务器发送要求确认的消息。wow中的session key应该是保存在一个类似于WorldServerMgr的地方,或者如mangos一样,就是保存在了数据库中。不管是怎样一种方式,了解了其过程,代码实现都是比较简单的,我们就不再赘述了。

    有关登录服的讨论或许该告一段落了吧。
作者:wallwind 发表于2013-8-23 16:39:01  原文链接
阅读:547 评论:1  查看评论
 
[转]linux的信号实践
当服务器close一个连接时,若client端接着发数据。根据TCP 协议的规定,会收到一个RST响应,client再往这个服务器发送数据时,系统会发出一个SIGPIPE信号给进程,告诉进程这个连接已经断开了,不要再写了。 
  根据信号的默认处理规则SIGPIPE信号的默认执行动作是terminate(终止、退出),所以client会退出。若不想客户端退出可以把SIGPIPE设为SIG_IGN 
  如:    signal(SIGPIPE,SIG_IGN); 
  这时SIGPIPE交给了系统处理。 
  服务器采用了fork的话,要收集垃圾进程,防止僵尸进程的产生,可以这样处理: 
  signal(SIGCHLD,SIG_IGN); 交给系统init去回收。 
  这里子进程就不会产生僵尸进程了。 
  http://www.cublog.cn/u/31357/showart_242605.html 好久没做过C开发了,最近重操旧业。  
  听说另外一个项目组socket开发遇到问题,发送端和接受端数据大小不一致。建议他们采用writen的重发机制,以避免信号中断错误。采用后还是有问题。PM让我帮忙研究下。 
  UNP n年以前看过,很久没做过底层开发,手边也没有UNP vol1这本书,所以做了个测试程序,研究下实际可能发生的情况了。 
  测试环境:AS3和redhat 9(缺省没有nc) 
  先下载unp源码: 
  #include    "unp.h" 
  #defineMAXBUF 40960 
  voidprocessSignal(intsigno) 
  { 
  printf("Signal is %d\n",signo); 
  signal(signo,processSignal); 
  } 
  void 
  str_cli(FILE*fp,intsockfd) 
  { 
  char    sendline[MAXBUF],recvline[MAXBUF]; 
  while(1){ 
  memset(sendline,'a',sizeof(sendline)); 
  printf("Begin send %d data\n",MAXBUF); 
  Writen(sockfd,sendline,sizeof(sendline)); 
  sleep(5); 
  } 
  } 
  int 
  main(intargc,char**argv) 
  { 
  int                    sockfd; 
  structsockaddr_in    servaddr; 
  signal(SIGPIPE,SIG_IGN); 
  //signal(SIGPIPE, processSignal); 
  if(argc!=2) 
  err_quit("usage: tcpcli [port]"); 
  sockfd=Socket(AF_INET,SOCK_STREAM,0); 
  bzero(&servaddr,sizeof(servaddr)); 
  servaddr.sin_family=AF_INET; 
  servaddr.sin_port=htons(atoi(argv[1])); 
  Inet_pton(AF_INET,"127.0.0.1",&servaddr.sin_addr); 
  Connect(sockfd,(SA*)&servaddr,sizeof(servaddr)); 
  str_cli(stdin,sockfd);        /* do it all */ 
  exit(0); 
  } 
  为了方便观察错误输出,lib/writen.c也做了修改,加了些日志: /* include writen */ 
  #include    "unp.h" 
  ssize_t                        /* Write "n" bytes to a descriptor. */ 
  writen(intfd,constvoid*vptr,size_tn) 
  { 
  size_t        nleft; 
  ssize_t        nwritten; 
  constchar    *ptr; 
  ptr=vptr; 
  nleft=n; 
  while(nleft>0){ 
  printf("Begin Writen %d\n",nleft); 
  if((nwritten=write(fd,ptr,nleft))socket中断,发送端write会返回-1,errno号为EPIPE(32) 
  测试2 catch SIGPIPE信号,writen之前,对方关闭接受进程 
  修改客户端代码,catch sigpipe信号 本机服务端: Begin send 40960 data 
  Begin Writen 40960 
  Already write 40960, left 0, errno=0 
  Begin send 40960 data 
  Begin Writen 40960 
  Already write 40960, left 0, errno=0 
  执行到上步停止服务端,client会继续显示: 
  Begin send 40960 data 
  Begin Writen 40960 
  Signal is 13 
  writen error: Broken pipe(32) 
  结论:可见write之前,对方socket中断,发送端write时,会先调用SIGPIPE响应函数,然后write返回-1,errno号为EPIPE(32) 
  为了方便操作,加大1次write的数据量,修改MAXBUF为4096000 
  本机服务端: Already write 589821, left 3506179, errno=0 
  Begin Writen 3506179 
  writen error: Connection reset by peer(104) 
  结论:可见socket write中,对方socket中断,发送端write会先返回已经发送的字节数,再次write时返回-1,errno号为ECONNRESET(104) 
  为什么以上测试,都是对方已经中断socket后,发送端再次write,结果会有所不同呢。从后来找到的UNP5.12,5.13能找到答案 以上解释了测试3的现象,write时,收到RST. What happens if the client ignores the error return from readline and writes more data to the server? This can happen, for example, if the client needs to perform. two writes to the server before reading anything back, with the first write eliciting the RST. The rule that applies is: When a process writes to a socket that has received an RST, the SIGPIPE signal is sent to the process. The default action of this signal is to terminate the process, so the process must catch the signal to avoid being involuntarily terminated. If the process either catches the signal and returns from the signal handler, or ignores the signal, the write operation returns EPIPE. 以上解释了测试1,2的现象,write一个已经接受到RST的socket,系统内核会发送SIGPIPE给发送进程,如果进程catch/ignore这个信号,write都返回EPIPE错误. 
  因此,UNP建议应用根据需要处理SIGPIPE信号,至少不要用系统缺省的处理方式处理这个信号,系统缺省的处理方式是退出进程,这样你的应用就很难查处处理进程为什么退出。 在 Unix系统下,如果send在等待协议传送数据时网络断开的话,调用send的进程会接收到一个SIGPIPE信号,进程对该信号的默认处理是进程终止。 
  在Unix系统下,如果recv函数在等待协议接收数据时网络断开了,那么调用recv的进程会接收到一个SIGPIPE信号,进程对该信号的默认处理是进程终止。 
  处理方法: 
  在初始化时调用signal(SIGPIPE,SIG_IGN)忽略该信号(只需一次) 
  其时send或recv函数将返回-1,errno为EPIPE,可视情况关闭socket或其他处理 
  gdb: 
  gdb默认收到sigpipe时中断程序,可调用handle SIGPIPE nostop print 
  相关 
  (1)SIG_DFL信号专用的默认动作: 
  (a)如果默认动作是暂停线程,则该线程的执行被暂时挂起。当线程暂停期间,发送给线程的任何附加信号都不交付,直到该线程开始执行,但是SIGKILL除外。 
  (b)把挂起信号的信号动作设置成SIG_DFL,且其默认动作是忽略信号 (SIGCHLD)。 
  (2)SIG_IGN忽略信号 
  (a)该信号的交付对线程没有影响 
  (b)系统不允许把SIGKILL或SIGTOP信号的动作设置为SIG_DFL 
  (3)指向函数的指针--捕获信号 
  (a)信号一经交付,接收线程就在指定地址上执行信号捕获程序。在信号捕 获函数返回后,接受线程必须在被中断点恢复执行。 
  (b)用C语言函数调用的方法进入信号捕捉程序: 
  void func (signo) 
  int signo; 
  func( )是指定的信号捕捉函数,signo是正被交付信号的编码 
  (c)如果SIGFPE,SIGILL或SIGSEGV信号不是由C标准定义的kill( )或raise( )函数所生成,则从信号SIGFPE,SIGILL,SIGSEGV的信号捕获函数正常返回后线程的行为是未定义的。 
  (d)系统不允许线程捕获SIGKILL和SIGSTOP信号。 
  (e)如果线程为SIGCHLD信号建立信号捕获函数,而该线程有未被等待的以终止的子线程时,没有规定是否要生成SIGCHLD信号来指明那个子线程。 
  每一种信号都被OSKit给予了一个符号名,对于32位的i386平台而言,一个字32位,因而信号有32种。下面的表给出了常用的符号名、描述和它们的信号值。 
  符号名 信号值 描述 是否符合POSIX 
  SIGHUP 1 在控制终端上检测到挂断或控制线程死亡是 
  SIGINT 2 交互注意信号是 
  SIGQUIT 3 交互中止信号是 
  SIGILL 4 检测到非法硬件的指令是 
  SIGTRAP 5 从陷阱中回朔否 
  SIGABRT 6 异常终止信号是 
  SIGEMT 7 EMT 指令否 
  SIGFPE 8 不正确的算术操作信号是 
  SIGKILL 9 终止信号是 
  SIGBUS 10 总线错误否 
  SIGSEGV 11 检测到非法的内存调用是 
  SIGSYS 12 系统call的错误参数否 
  SIGPIPE 13 在无读者的管道上写是 
  SIGALRM 14 报时信号是 
  SIGTERM 15 终止信号是 
  SIGURG 16 IO信道紧急信号否 
  SIGSTOP 17 暂停信号是 
  SIGTSTP 18 交互暂停信号是 
  SIGCONT 19 如果暂停则继续是 
  SIGCHLD 20 子线程终止或暂停是 
  SIGTTIN 21 后台线程组一成员试图从控制终端上读出是 
  SIGTTOU 22 后台线程组的成员试图写到控制终端上是 
  SIGIO 23 允许I/O信号否 
  SIGXCPU 24 超出CPU时限否 
  SIGXFSZ 25 超出文件大小限制否 
  SIGVTALRM 26 虚时间警报器否 
  SIGPROF 27 侧面时间警报器否 
  SIGWINCH 28 窗口大小的更改否 
  SIGINFO 29 消息请求否 
  SIGUSR1 30 保留作为用户自定义的信号1是 
  SIGUSR2 31 保留作为用户自定义的信号是  
  注意:Linux信号机制基本上是从Unix系统中继承过来的。早期Unix系统中的信号机制比较简单和原始,后来在实践中暴露出一些问题,因此,把那些建立在早期机制上的信号叫做"不可靠信号",信号值小于SIGRTMIN(Red hat 7.2中,SIGRTMIN=32,SIGRTMAX=63)的信号都是不可靠信号。这就是"不可靠信号"的来源。它的主要问题是:进程每次处理信号后,就将对信号的响应设置为默认动作。在某些情况下,将导致对信号的错误处理;因此,用户如果不希望这样的操作,那么就要在信号处理函数结尾再一次调用signal(),重新安装该信号。 另外,我再做一些补充,产生RST响应以至于系统发出SIGPIPE信号,应该分为两种情况: 
  1. 客户端到服务端之间网络断掉,或者服务端断电等,物理连接断掉了,这种情况下客户端不会退出,send函数正常执行,不会感觉到自己出错。因为由于物理网络断开,服务端不会给客户端回应错误消息,没有RST响应,自然也不会产生SIGPIPE信号。但是当服务端再恢复正常的时候,对客户端send来的消息会产生RST响应,客户端就收到SIGPIPE信号了,程序退出,但是这时send函数是能够返回 -1的。可以进行异常处理。 
  2.客户端到服务端的网络能通,服务程序挂掉,客户端程序会马上退出,因为服务端能正常返回错误消息,客户端收到,SIGPIPE信号就产生了。不过我不确定此时服务端返回是的RST响应,抓包来看没有RST标志。水平有限,只写到这了。
作者:wallwind 发表于2013-8-19 16:57:08  原文链接
阅读:124 评论:0  查看评论
 
[原]游戏登陆流程

今天主要讲游戏的登陆流程,
由于我们的后台架构是前端接入层+后端业务进程的架构模式,因此,任何网络连接请求的数据,都要经过前端接入。
首先要说明,目前大多数游戏都是 账号+角色的模式。ok
登陆两种模式1,已在该服创建过账号,创建过角色的玩家
2.在该服没有创建过,账号数据库是没有数据


我们首先说一下我们的进程模式


连接层+业务层+认证层
比如,当一个玩家要玩我们的游戏,首先,客户端发送上来的协议字段是没有具体的账号信息的,
因此我们认为该账号没哟经过认证过的,所以,通过业务层的一个特殊方法将协议数据转发给认证层,
那么认证层做什么事情,他首先去查账号数据库表,是否该账号在该服建立过。
1,新人,数据库是不纯在。那么我们将上送来的账号插入到数据库,同时会根据数据库生成一个uin,这个uin是所有唯一的,长度是8个字节,
使用uin标识改账号的唯一,然后通过一些细节处理,比如加密,打乱等简单处理,在讲该数据,保存到账号cach中,发给前端接入层,
然后在将基本的已认证信息发给玩家,当玩家收到该数据后,表示,该玩家已经在该服有账号了,然后,通过账号登陆,将基本账号数据发送给业务进程,


表示认证通过,我们会通过uin,然后,在通过请求,从角色数据表中查询是否有已有角色创建,下发角色列表。
创建角色,业务进程将会从玩家内存池中,分配一段内存,给该角色。


2.我们会首先从账号缓存中取得数据,如果存在,就认证成功,如果不存在,就去查数据库,然后判断是否存在,在走上诉流程


3,如果玩家已在该服玩过,那么我们会直接从数据库把基本数据插入到缓存里。


这些基本的一个登陆流程了。
作者:wallwind 发表于2013-8-15 11:08:18  原文链接
阅读:284 评论:1  查看评论
 
[转]函数inet_addr和inet_ntoa

函数inet_addr和inet_ntoa

Posted on 2010-08-13 17:46  kongkongzi 阅读(3808)  评论(0)   编辑  收藏  引用 所属分类:  network programming 

inet_addr 将"数字+句点"的格式的IP地址转换到unsigned long中,返回值已经是按照网络字节顺序的
相反inet_ntoa把类型为struct in_addr的数据转化为"数字+句点"的形式的字符串
typedef u_int32_t in_addr_t;
struct in_addr
{
       in_addr_t s_addr;
};

本机字节顺序与网络字节顺序的转换
#include <arpa/inet.h>
htons  ------"host to network short"
htonl   -------"host to network long"
ntohs  -------"network to host short"
ntohl   -------"network to host long"
*注意:在你的数据放到网络上的时候,确信它是网络字节顺序
网络字节顺序(大端字节)和x86机器字节顺序(小端字节)
eg:0X3132  在x86上显示21  在网络传输中为12

inet_addr返回的整数形式是网络字节序,而inet_network返回的整数形式是主机字节序。他俩都有一个小缺陷,
那就是当IP是255.255.255.255时,这两个函数会认为这是个无效的IP地址,这是历史遗留问题,其实在目前大部
分的路由器上,这个255.255.255.255的IP都是有效的。
inet_aton函数和上面这俩个函数的区别就是在于他认为255.255.255.255是有效的,他不会冤枉这个看似特殊的IP地址。对了,inet_aton函数返回的是网络字节序的IP地址。

综上所述,应该使用inet_aton和inet_ntoa这一对函数。


资料:

#include <sys/socket.h>
#include <netinet/ in.h>
#include <arpa/inet.h>

typedef uint32_t in_addr_t;

int inet_aton( const  char *cp,  struct in_addr *inp);
in_addr_t inet_addr( const  char *cp);
in_addr_t inet_network( const  char *cp);
char *inet_ntoa( struct in_addr  in);
struct in_addr inet_makeaddr( int net,  int host);
in_addr_t inet_lnaof( struct in_addr  in);
in_addr_t inet_netof( struct in_addr  in);


 

//  Internet address.
struct in_addr {
        union {
                 struct { u_char s_b1,s_b2,s_b3,s_b4; } S_un_b;
                 struct { u_short s_w1,s_w2; } S_un_w;
                u_long S_addr;  /*  port in network byte order  */
        } S_un;
#define s_addr  S_un.S_addr
};
//  Socket address, internet style.
struct sockaddr_in {         //  struct sockaddr的一种特殊形式
         short            sin_family;     /*  address family: AF_INET  */
        u_short        sin_port;         /*  port in network byte order  */
         struct in_addr sin_addr;         /*  port in network byte order  */
         char            sin_zero[8];     /*  8 byte pad  */
};
//  Structure used by kernel to store most addresses.
struct sockaddr {
        u_short sa_family;  /*  address family  */
         char    sa_data[14];  /*  up to 14 bytes of direct address  */
};

struct in_addr {
    unsigned  long  int s_addr;
}
作者:wallwind 发表于2013-8-12 19:05:33  原文链接
阅读:92 评论:0  查看评论
 
[原]游戏后台之内存管理篇



服务端程序对于内存的管理上是重中之中,如何管理好程序的内存是保证程序稳定的最重要因素。


因此,我们是如何做的呢。


1.我们知道,当有一个新玩家进入游戏,我们需要分配一段内存给这个玩家,当这个玩家下线了,不玩了,我们就要对这段内存
进行清理。因此,如何有效的管理这段内存,如何能重新利用这段内存,是我们的问题,因此,使用内存池的方式,是比较理想的
一种方式。
通过内存池,我们可以预分配一大块数据使用,下线的玩家之后,那段内存是可以重新使用的。
目前游戏中,使用到内存池有玩家,
2.固定不变的数据。游戏里有配置表数据,这些数据是玩家在游戏过程中,需要使用的数据,比如任务表,装备表的数据等等,这些数据是固定不变的,
因此我们就放在一个固定的数组里,一张二维表数据,相对于数组而言就是二维数组,因此,定义响应表的二维数组。当系统系统启动的时候,加载进去,并排好序,
3.为了快速查找的数据,我们使用hash内存,查找速度几乎是常数。


为了我们的程序具有coredump的时候,玩家的数据不会丢失,因此,我们的方案使用了共享内存的方式,即使程序coredump了,我们整个游戏的数据和core之前的数据保持一致。


经过以上描述,我们的服务端,使用了主要技术为1,共享内存,内存池,hash内存,二分查找算法等,
经过实践,我们的游戏是非常稳定,不会丢失数据等状况

作者:wallwind 发表于2013-7-27 1:29:35  原文链接
阅读:267 评论:0  查看评论
 
[转]gcc同时使用动态和静态链接

最近因为项目的makefile同时使用了静态动态的连接库,所以,就要同事的链接进去

我们知道gcc的-static选项可以使链接器执行静态链接。但简单地使用-static显得有些’暴力’,因为他会把命令行中-static后面的所有-l指明的库都静态链接,更主要的是,有些库可能并没有提供静态库(.a),而只提供了动态库(.so)。这样的话,使用-static就会造成链接错误。

之前的链接选项大致是这样的,

1 CORE_LIBS="$CORE_LIBS -L/usr/lib64/mysql -lmysqlclient -lz -lcrypt -lnsl -lm -L/usr/lib64 -lssl -lcrypto"

修改过是这样的,

1 2 CORE_LIBS="$CORE_LIBS -L/usr/lib64/mysql -Wl,-Bstatic -lmysqlclient -Wl,-Bdynamic -lz -lcrypt -lnsl -lm -L/usr/lib64 -lssl -lcrypto"

  其中用到的两个选项:-Wl,-Bstatic和-Wl,-Bdynamic。这两个选项是gcc的特殊选项,它会将选项的参数传递给链接器,作为链接器的选项。比如-Wl,-Bstatic告诉链接器使用-Bstatic选项,该选项是告诉链接器,对接下来的-l选项使用静态链接;-Wl,-Bdynamic就是告诉链接器对接下来的-l选项使用动态链接。下面是man gcc对-Wl,option的描述,

-Wl,option Pass option as an option to the linker. If option contains commas, it is split into multiple options at the commas. You can use this syntax to pass an argument to the option. For example, -Wl,-Map,output.map passes -Map output.map to the linker. When using the GNU linker, you can also get the same effect with -Wl,-Map=output.map.

下面是man ld分别对-Bstatic和-Bdynamic的描述,

-Bdynamic -dy -call_shared Link against dynamic libraries. You may use this option multiple times on the command line: it affects library searching for -l options which follow it. -Bstatic -dn -non_shared -static Do not link against shared libraries. You may use this option multiple times on the command line: it affects library searching for -l options which follow it. This option also implies --unresolved-symbols=report-all. This option can be used with -shared. Doing so means that a shared library is being created but that all of the library's external references must be resolved by pulling in entries from static libraries.

  值得注意的是对-static的描述:-static和-shared可以同时存在,这样会创建共享库,但该共享库引用的其他库会静态地链接到该共享库中。




作者:wallwind 发表于2013-7-9 0:11:35  原文链接
阅读:188 评论:0  查看评论
 
[转]SO_KEEPALIVE选项

SO_KEEPALIVE

在《UNIX网络编程第1卷》中也有详细的阐述:

SO_KEEPALIVE 保持连接检测对方主机是否崩溃,避免(服务器)永远阻塞于TCP连接的输入。设置该选项后,如果2小时内在此套接口的任一方向都没有数据交换,TCP就自 动给对方 发一个保持存活探测分节(keepalive probe)。这是一个对方必须响应的TCP分节.它会导致以下三种情况:对方接收一切正常:以期望的ACK响应。2小时后,TCP将发出另一个探测分 节。对方已崩溃且已重新启动:以RST响应。套接口的待处理错误被置为ECONNRESET,套接 口本身则被关闭。对方无任何响应:源自berkeley的TCP发送另外8个探测分节,相隔75秒一个,试图得到一个响应。在发出第一个探测分节11分钟 15秒后若仍无响应就放弃。套接口的待处理错误被置为ETIMEOUT,套接口本身则被关闭。如ICMP错误是“host unreachable(主机不可达)”,说明对方主机并没有崩溃,但是不可达,这种情况下待处理错误被置为 EHOSTUNREACH。

根据上面的介绍我们可以知道对端以一种非优雅的方式断开连接的时候,我们可以设置SO_KEEPALIVE属性使得我们在2小时以后发现对方的TCP连接是否依然存在。   

keepAlive = 1;

Setsockopt(listenfd, SOL_SOCKET, SO_KEEPALIVE, (void*)&keepAlive, sizeof(keepAlive));


如果我们不能接受如此之长的等待时间,从TCP-Keepalive-HOWTO上可以知道一共有两种方式可以设置,一种是修改内核关于网络方面的 配置参数,另外一种就是SOL_TCP字段的TCP_KEEPIDLE, TCP_KEEPINTVL, TCP_KEEPCNT三个选项。

1) The tcp_keepidle parameter specifies the interval of inactivity that causes TCP to generate a KEEPALIVE transmission for an application that requests them. tcp_keepidle defaults to 14400 (two hours).

/*开始首次KeepAlive探测前的TCP空闭时间 */

 

2) The tcp_keepintvl parameter specifies the interval between the nine retries that are attempted if a KEEPALIVE transmission is not acknowledged. tcp_keepintvl defaults to 150 (75 seconds).
    /* 两次KeepAlive探测间的时间间隔  */


    3) The tcp_keepcnt option specifies the maximum number of keepalive probes to be sent. The value of TCP_KEEPCNT is an integer value between 1 and n, where n is the value of the systemwide tcp_keepcnt parameter.

/* 判定断开前的KeepAlive探测次数

    

int                 keepIdle = 1000;

int                 keepInterval = 10;

int                 keepCount = 10;

Setsockopt(listenfd, SOL_TCP, TCP_KEEPIDLE, (void *)&keepIdle, sizeof(keepIdle));

Setsockopt(listenfd, SOL_TCP,TCP_KEEPINTVL, (void *)&keepInterval, sizeof(keepInterval));

Setsockopt(listenfd,SOL_TCP, TCP_KEEPCNT, (void *)&keepCount, sizeof(keepCount));


Remember that keepalive is not program?related, but socket?related, so if you have multiple sockets, you can handle keepalive for each of them separately.


作者:wallwind 发表于2013-6-25 0:25:27  原文链接
阅读:184 评论:0  查看评论
 
[原]游戏系统开发设计分享

我所搭建的总体架构非常简单就是
前端接入进程 +后端业务逻辑处理进程+数据库缓存进程+其他协作进程
一、组件基本介绍
1.通信组件 所有进程使用的是单线程,没有使用其他线程。进程间通信使用我们的一个自主开发的通信组件。
2.数据协议组件 我们也是自主开发的一个以xml表现形式,通过工具生成.h头文件,二进制bin文件。数据加载文件等。也可以和数据库进行交互的数据文件,也可以进行网络传输等,功能十分强大。




游戏的基本组件详细实现就不介绍了,本文主要介绍如何通过这些很好的组件来搭建可用性,稳定性,可扩展性的一个游戏项目


二、框架
单独进程的游戏,内部函数运行状态有多种,比如收到某个信号。等下面主要介绍一下几个回调函数


1.进程初始化函数,也就是该函数在程序启动的时候,对配置文件和一些组件等初始化。
2.时间驱动函数,该函数会每秒钟运行一次。
3.消息处理函数,实质是一个死循环,不停的的接收其他模块或者网络过来的消息,进行处理
4.重载函数。当该进程,有一些配置等需要热更新的东西,可以直接调用该函数进行重载
5.进程退出函数。当收到某个信号后,进程需要安全退出的
。。。。
ok,当我们把这几种模块回调函数定义好,框架基本就可以起来了。然后通过初始化一些组件信息,用来进行进程间通信,数据描述等进行了启动进程
目前已经有了具体的前端接入的进程,通过组件的共享内存方式发送到后端业务进程。


三、游戏的基本元素
1.地图 
对于我们的mmorpg游戏来说,地图是最基础的东西了。当玩家进入游戏,首先进入的是一个世界的概念,就是在一张张地图上来跑动,和其他玩家进行互动。
来模拟真实的生活环境。
那么我们的地图是如和实现呢。
首先要知道地图是以像素为单位,在一个二维的平面地图上。每个像素点都是有坐标的,但是每张地图非常巨大,不可能把每个坐标点记录下来。因此我们程序就要对一些点的集合进行处理。
既要游戏的体验感好,又要程序处理速度快。就要选择合理的小格子。就我经历过的项目而言,有菱形的,有正方形的,有矩形的等等,根据需求进行处理。我们的是矩形,边长为30*50像素的矩形
而这个矩形,就代表了很多信息,我们通过用一个字节来表达这个格子的信息,比如第一位是阻挡信息,第二位是技能层等等,有八位供选择。前后端要约定好
然后,当美术给我们了一张地图,我们会开发一个地图编辑器工具,将地图按照规定的小格子,和前后端约定好的数据信息,生成一个地图mask文件。提供给后端使用。
ok,一些基本的数据信息我们已经得到了,我们就需要提供一些具体的函数了,通过给我们的mask文件,进行一些必要校验,比如,我们在地图行走,下一点是否是阻碍点。
当我们释放某些技能,是否能通过。是否到达了地图的边界,扇形攻击的角度,范围等等函数,这里是需要一些数学的基础知识等。
为了达到我们游戏的一个体验感比较真实的效果和计算机速度等因素,我们对地图有进行了一个更大区域的划分。叫动态区域。每个动态区域都有固定的大小。这是根据我们游戏屏幕进行划分的。将屏幕划分为九个区域,每个区域主要是包含了一些实体的内存实例id,比如当玩家进入这个区域我们会将该玩家的内存id加入该区域呢,离开就删除。这是个动态的过程。


每张地图都是有一个tick,也就是上文我们提到的tick。


也就是我们保证,当玩家看到你了,一定是我也能看到对方。这个时候,我们就通过这写来调用数据包发送协议,将自己的数据发送到对方。


因为有个tick,因此,在内存中的玩家,每秒钟都会进行tick,每秒钟都会进行对玩家的所在地图,进行动态区域的更新。


同样,怪物也是同样的道理,我们通过tick,对地图上的怪物进行处理,如果怪物死亡,就将其id删除,更新视野。然后根据具体业务具体处理了


2.人,怪,物表现


如何通过计算机,然后表现在地图上呢。
首先我们知道,我们所有的实体在计算机里,其实都是一段内存表现,通过这段内存,记录了我们所有的需要记录的数据。比如账号名,在线信息等等。
那么,我们首先预分配好一个巨大的内存池。当有个新的账号登陆的时候,我们就在内存池里取一段内存。此时我们已经记录到了数据库了。
同样,我们也分配了怪物的内存,等等。


3.技能系统。
技能系统算是所有系统里最复杂的东西了。设计的东西非常多。比如当玩家打出去一个技能,伤害,mp,状态等等等等,都会对伤害进行修正,打出去有什么效果。
在地图上怎么表现,能否穿过障碍等等。
4.任务系统
任务系统主要是有接受,完成,奖励的等,整体来说不是困难。
5.背包,
也是最复杂的一个,主要是背包的一些操作吧。整理,堆叠移动等等,设计大量的算法等。


还有很多系统,目前所有的系统我都设计开发,目前主要是致力于防刷,性能提升,bug漏洞修复等等。
下篇文章可能主要介绍网络接入这块,还有具体的登陆这块
开发游戏还是蛮有乐趣的,以后有心得继续给大家分享。





作者:wallwind 发表于2013-6-9 0:59:23  原文链接
阅读:782 评论:3  查看评论
 
[转]TCP滑动窗口和socket缓冲区之间的关系(记录)

一、TCP的滑动窗口大小实际上就是socket的接收缓冲区大小的字节数

二、对于server端的socket一定要在listen之间设置缓冲区大小,因为,accept时新产生的socket会继承监听socket的缓冲区大小。对于client端的socket一定要在connet之前设置缓冲区大小,因为connet时需要进行三次握手过程,会通知对方自己的窗口大小。在connet之后再设置缓冲区,已经没有什么意义。

三、由于缓冲区大小在TCP头部只有16位来表示,所以它的最大值是65536,但是对于一些情况来说需要使用更大的滑动窗口,这时候就要使用扩展的滑动窗口,如光纤高速通信网络,或者是卫星长连接网络,需要窗口尽可能的大。这时会使用扩展的32位的滑动窗口大小。

四、滑动窗口听移动规则:

1、窗口合拢:在收到对端数据后,自己确认了数据的正确性,这些数据会被存储到缓冲区,等待应用程序获取。但这时候因为已经确认了数据的正确性,需要向对方发送确认响应ACK,又因为这些数据还没有被应用进程取走,这时候便需要进行窗口合拢,缓冲区的窗口左边缘向右滑动。注意响应的ACK序号是对方发送数据包的序号,一个对方发送的序号,可能因为窗口张开会被响应(ACK)多次。

2、窗口张开:窗口收缩后,应用进程一旦从缓冲区中取出数据,TCP的滑动窗口需要进行扩张,这时候窗口的右边缘向右扩张,实际上窗口这是一个环形缓冲区,窗口的右边缘扩张会使用原来被应用进程取走内容的缓冲区。在窗口进行扩张后,需要使用ACK通知对端,这时候ACK的序号依然是上次确认收到包的序号。

3、窗口收缩,窗口的右边缘向左滑动,称为窗口收缩,Host Requirement RFC强烈建议不要这样做,但TCP必须能够在某一端产生这种情况时进行处理

作者:wallwind 发表于2013-6-4 9:49:11  原文链接
阅读:373 评论:0  查看评论
 
[原]游戏地图掩码相关(msk)

      在游戏的世界里,玩家在地图上的某点,是否能够走动,是否遇到障碍,是否是走到了阴影处,是否水层等等先关信息都要我们前后端知道。

那么服务器是如何进行实现的呢。下面主要给大家讲讲。

     首先,我们知道图片是以像素为主要为单位进行计量,但是我们后端又不能使用这个东西,在二维的世界观里,我们是以坐标(x,y)具体的表现出其某个东西,所在的位置。因此,我们就要通过这个像素来表达出地点。

     

      想象一下啊,当我们确定到一个坐标的时候,但我们将其慢慢变大,那个小点就开始显示长宽。因此我们也用其原理。因此,我们是以将地图划分为很多个小格子,这些小格子,其实就代表了所谓的一个点,那么这个小格子是多大呢,这里我们一不超过50的为单位,作为长和宽。

      那这个小格子怎么样去表达具体的信息呢,处,每个因此,我们约定,用1个字节来表其信息,一个字节八位0000 0000,每个位具体可以表示什么含义,比如,第一位如果0表示可行走,1表示障碍。第二位0表示无遮掩,1表示遮掩。等,这里我就不一一举出。不同游戏有不同的具体表达信息。

       好,那么我们划分了很多歌小格子,每一行都有相同的小格子,那么我们就知道了这个地图,长有多少个各自,高有多少个格子。

设计一个结构体,头

  1. struct tagMapHead  
  2. {  
  3.     int     m_width;//地图的宽  
  1. int     m_height;地图的高  
  1. short   m_tileSize;//小格子的变长  
  1. };  


在这里,我们用了正方形表达,其实我们可以用长方形,我还见过菱形的,各个游戏不一样 。然后,头信息主要是这些,然后,将通过地图编辑器,把每个格子根据地图的基本信息,画图。然后通过工具具体生成。

头+包体。就生成了msk文件。

 

当前端生成了msk后,我们后端开始对其进行数据解析了。

服务端的具体存数据是

  1. struct TMapMask  
  2. {  
  3.     int     m_iSize;  
  4.     int   m_lWidthMasks;  
  5.     int   m_lHeightMasks;  
  6.     int   m_lMaskPixelWidth;  
  7.     int   m_lMaskPixelHeight;  
  8.   
  9.     MASK_BIT_TYPE   m_pMaskData[1];  
  10. };  


 

根据msk二进制文件,后端进行解析,因为,我们每个地图,还有个基本的信息配置表,因此,我们就根据其掩码信息,将其一些数据附加到地图的结构体里。

比如,地图最大坐标,地图大小,按照我们的规定,这个地图有哪些动态区域。(动态区域,以后回去讲解),等等数据信息,供以后我们在地图上使用。

在这里地图掩码主要讲解完毕。

以后会将,我们是如何在地图上,看到玩家的。

 

 

作者:wallwind 发表于2013-5-30 0:49:29  原文链接
阅读:756 评论:1  查看评论
 
[转]epoll的内核实现

epoll是由一组系统调用组成。
     int epoll_create(int size);
     int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
     int epoll_wait(int epfd, struct epoll_event *events,int maxevents, int timeout);
     select/poll的缺点在于:
     1.每次调用时要重复地从用户态读入参数。
     2.每次调用时要重复地扫描文件描述符。
     3.每次在调用开始时,要把当前进程放入各个文件描述符的等待队列。在调用结束后,又把进程从各个等待队列中删除。
     在实际应用中,select/poll监视的文件描述符可能会非常多,如果每次只是返回一小部分,那么,这种情况下select/poll

显得不够高效。epoll的设计思路,是把select/poll单个的操作拆分为1个epoll_create+多个epoll_ctrl+一个epoll_wait。

epoll机制实现了自己特有的文件系统eventpoll filesystem

[cpp]  view plain copy
  1. /* File callbacks that implement the eventpoll file behaviour */  
  2. static const struct file_operations eventpoll_fops = {  
  3.     .release    = ep_eventpoll_release,  
  4.     .poll       = ep_eventpoll_poll  
  5. };  

epoll_create创建一个属于该文件系统的文件,然后返回其文件描述符。

 

struct eventpoll 保存了epoll文件节点的扩展信息,该结构保存于file结构体的private_data域中,每个epoll_create创建的epoll

描述符都分配一个该结构体。该结构的各个成员的定义如下,注释也很详细。

[cpp]  view plain copy
  1. /* 
  2.  * This structure is stored inside the "private_data" member of the file 
  3.  * structure and rapresent the main data sructure for the eventpoll 
  4.  * interface. 
  5.  */  
  6. struct eventpoll {  
  7.     /* Protect the this structure access,可用于中断上下文 */  
  8.     spinlock_t lock;  
  9.     /* 
  10.      * This mutex is used to ensure that files are not removed 
  11.      * while epoll is using them. This is held during the event 
  12.      * collection loop, the file cleanup path, the epoll file exit 
  13.      * code and the ctl operations.用户进程上下文中 
  14.      */  
  15.     struct mutex mtx;  
  16.     /* Wait queue used by sys_epoll_wait() */  
  17.     wait_queue_head_t wq;  
  18.     /* Wait queue used by file->poll() */  
  19.     wait_queue_head_t poll_wait;  
  20.     /* List of ready file descriptors */  
  21.     struct list_head rdllist;  
  22.     /* RB tree root used to store monitored fd structs */  
  23.     struct rb_root rbr;  
  24.     /* 
  25.      * This is a single linked list that chains all the "struct epitem" that 
  26.      * happened while transfering ready events to userspace w/out 
  27.      * holding ->lock. 
  28.      */  
  29.     struct epitem *ovflist;  
  30.     /* The user that created the eventpoll descriptor */  
  31.     struct user_struct *user;  
  32. };  

 

而通过epoll_ctl接口加入该epoll描述符监听的套接字则属于socket filesystem,这点一定要注意。每个添加的待监听(这里监听

和listen调用不同)都对应于一个epitem结构体,该结构体已红黑树的结构组织,eventpoll结构中保存了树的根节点(rbr成员)。

同时有监听事件到来的套接字的该结构以双向链表组织起来,链表头也保存在eventpoll中(rdllist成员)。

[c-sharp]  view plain copy
  1. /* 
  2.  * Each file descriptor added to the eventpoll interface will 
  3.  * have an entry of this type linked to the "rbr" RB tree. 
  4.  */  
  5. struct epitem {  
  6.     /* RB tree node used to link this structure to the eventpoll RB tree */  
  7.     struct rb_node rbn;  
  8.     /* List header used to link this structure to the eventpoll ready list */  
  9.     struct list_head rdllink;  
  10.     /* 
  11.      * Works together "struct eventpoll"->ovflist in keeping the 
  12.      * single linked chain of items. 
  13.      */  
  14.     struct epitem *next;  
  15.     /* The file descriptor information this item refers to */  
  16.     struct epoll_filefd ffd;  
  17.     /* Number of active wait queue attached to poll operations */  
  18.     int nwait;  
  19.     /* List containing poll wait queues */  
  20.     struct list_head pwqlist;  
  21.     /* The "container" of this item */  
  22.     struct eventpoll *ep;  
  23.     /* List header used to link this item to the "struct file" items list */  
  24.     struct list_head fllink;  
  25.     /* The structure that describe the interested events and the source fd */  
  26.     struct epoll_event event;  
  27. };  

 

epoll_create的调用很简单,就是创建一个epollevent的文件,并返回文件描述符。

epoll_ctl用来添加,删除以及修改监听项。

[c-sharp]  view plain copy
  1. /* 
  2.  * The following function implements the controller interface for 
  3.  * the eventpoll file that enables the insertion/removal/change of 
  4.  * file descriptors inside the interest set. 
  5.  */  
  6. SYSCALL_DEFINE4(epoll_ctl, int, epfd, int, op, int, fd,  
  7.         struct epoll_event __user *, event)  
  8. {  
  9.     int error;  
  10.     struct file *file, *tfile;  
  11.     struct eventpoll *ep;  
  12.     struct epitem *epi;  
  13.     struct epoll_event epds;  
  14.     DNPRINTK(3, (KERN_INFO "[%p] eventpoll: sys_epoll_ctl(%d, %d, %d, %p)/n",  
  15.              current, epfd, op, fd, event));  
  16.     error = -EFAULT;  
  17.     if (ep_op_has_event(op) &&  
  18.         copy_from_user(&epds, eventsizeof(struct epoll_event)))  
  19.         goto error_return;  
  20.     /* Get the "struct file *" for the eventpoll file */  
  21.     error = -EBADF;  
  22.     file = fget(epfd);  
  23.     if (!file)  
  24.         goto error_return;  
  25.     /* Get the "struct file *" for the target file */  
  26.     tfile = fget(fd);  
  27.     if (!tfile)  
  28.         goto error_fput;  
  29.     /* The target file descriptor must support poll */  
  30.     error = -EPERM;  
  31.     if (!tfile->f_op || !tfile->f_op->poll)  
  32.         goto error_tgt_fput;  
  33.     /* 
  34.      * We have to check that the file structure underneath the file descriptor 
  35.      * the user passed to us _is_ an eventpoll file. And also we do not permit 
  36.      * adding an epoll file descriptor inside itself. 
  37.      */  
  38.     error = -EINVAL;  
  39.     if (file == tfile || !is_file_epoll(file))  
  40.         goto error_tgt_fput;  
  41.     /* 
  42.      * At this point it is safe to assume that the "private_data" contains 
  43.      * our own data structure. 
  44.      */  
  45.     ep = file->private_data;  
  46.     mutex_lock(&ep->mtx);  
  47.     /* 
  48.      * Try to lookup the file inside our RB tree, Since we grabbed "mtx" 
  49.      * above, we can be sure to be able to use the item looked up by 
  50.      * ep_find() till we release the mutex. 
  51.      */  
  52.     epi = ep_find(ep, tfile, fd);  
  53.     error = -EINVAL;  
  54.     switch (op) {  
  55.     case EPOLL_CTL_ADD:  
  56.         if (!epi) {  
  57.             epds.events |= POLLERR | POLLHUP;  
  58.             error = ep_insert(ep, &epds, tfile, fd);  
  59.         } else  
  60.             error = -EEXIST;  
  61.         break;  
  62.     case EPOLL_CTL_DEL:  
  63.         if (epi)  
  64.             error = ep_remove(ep, epi);  
  65.         else  
  66.             error = -ENOENT;  
  67.         break;  
  68.     case EPOLL_CTL_MOD:  
  69.         if (epi) {  
  70.             epds.events |= POLLERR | POLLHUP;  
  71.             error = ep_modify(ep, epi, &epds);  
  72.         } else  
  73.             error = -ENOENT;  
  74.         break;  
  75.     }  
  76.     mutex_unlock(&ep->mtx);  
  77. error_tgt_fput:  
  78.     fput(tfile);  
  79. error_fput:  
  80.     fput(file);  
  81. error_return:  
  82.     DNPRINTK(3, (KERN_INFO "[%p] eventpoll: sys_epoll_ctl(%d, %d, %d, %p) = %d/n",  
  83.              current, epfd, op, fd, event, error));  
  84.     return error;  
  85. }  

同样,代码很清楚。先来看看添加流程

[c-sharp]  view plain copy
  1. /* 
  2.  * Must be called with "mtx" held. 
  3.  */  
  4. static int ep_insert(struct eventpoll *ep, struct epoll_event *event,  
  5.              struct file *tfile, int fd)  
  6. {  
  7.     int error, revents, pwake = 0;  
  8.     unsigned long flags;  
  9.     struct epitem *epi;  
  10.     struct ep_pqueue epq;  
  11.         /* 不允许超过最大监听个数*/  
  12.     if (unlikely(atomic_read(&ep->user->epoll_watches) >=  
  13.              max_user_watches))  
  14.         return -ENOSPC;  
  15.     if (!(epi = kmem_cache_alloc(epi_cache, GFP_KERNEL)))  
  16.         return -ENOMEM;  
  17.     /* Item initialization follow here ... */  
  18.     INIT_LIST_HEAD(&epi->rdllink);  
  19.     INIT_LIST_HEAD(&epi->fllink);  
  20.     INIT_LIST_HEAD(&epi->pwqlist);  
  21.     epi->ep = ep;  
  22.     ep_set_ffd(&epi->ffd, tfile, fd);  
  23.     epi->event = *event;  
  24.     epi->nwait = 0;  
  25.     epi->next = EP_UNACTIVE_PTR;  
  26.     /* Initialize the poll table using the queue callback */  
  27.     epq.epi = epi;  
  28.     init_poll_funcptr(&epq.pt, ep_ptable_queue_proc);  
  29.     /* 
  30.      * Attach the item to the poll hooks and get current event bits. 
  31.      * We can safely use the file* here because its usage count has 
  32.      * been increased by the caller of this function. Note that after 
  33.      * this operation completes, the poll callback can start hitting 
  34.      * the new item. 
  35.      */  
  36.     revents = tfile->f_op->poll(tfile, &epq.pt);  
  37.     /* 
  38.      * We have to check if something went wrong during the poll wait queue 
  39.      * install process. Namely an allocation for a wait queue failed due 
  40.      * high memory pressure. 
  41.      */  
  42.     error = -ENOMEM;  
  43.     if (epi->nwait < 0)  
  44.         goto error_unregister;  
  45.     /* Add the current item to the list of active epoll hook for this file */  
  46.     spin_lock(&tfile->f_ep_lock);  
  47.     list_add_tail(&epi->fllink, &tfile->f_ep_links);  
  48.     spin_unlock(&tfile->f_ep_lock);  
  49.     /* 
  50.      * Add the current item to the RB tree. All RB tree operations are 
  51.      * protected by "mtx", and ep_insert() is called with "mtx" held. 
  52.      */  
  53.     ep_rbtree_insert(ep, epi);  
  54.     /* We have to drop the new item inside our item list to keep track of it */  
  55.     spin_lock_irqsave(&ep->lock, flags);  
  56.     /* If the file is already "ready" we drop it inside the ready list */  
  57.     if ((revents & event->events) && !ep_is_linked(&epi->rdllink)) {  
  58.         list_add_tail(&epi->rdllink, &ep->rdllist);  
  59.         /* Notify waiting tasks that events are available */  
  60.         if (waitqueue_active(&ep->wq))  
  61.             wake_up_locked(&ep->wq);  
  62.         if (waitqueue_active(&ep->poll_wait))  
  63.             pwake++;  
  64.     }  
  65.     spin_unlock_irqrestore(&ep->lock, flags);  
  66.     atomic_inc(&ep->user->epoll_watches);  
  67.     /* We have to call this outside the lock */  
  68.     if (pwake)  
  69.         ep_poll_safewake(&psw, &ep->poll_wait);  
  70.     DNPRINTK(3, (KERN_INFO "[%p] eventpoll: ep_insert(%p, %p, %d)/n",  
  71.              current, ep, tfile, fd));  
  72.     return 0;  
  73. error_unregister:  
  74.     ep_unregister_pollwait(ep, epi);  
  75.     /* 
  76.      * We need to do this because an event could have been arrived on some 
  77.      * allocated wait queue. Note that we don't care about the ep->ovflist 
  78.      * list, since that is used/cleaned only inside a section bound by "mtx". 
  79.      * And ep_insert() is called with "mtx" held. 
  80.      */  
  81.     spin_lock_irqsave(&ep->lock, flags);  
  82.     if (ep_is_linked(&epi->rdllink))  
  83.         list_del_init(&epi->rdllink);  
  84.     spin_unlock_irqrestore(&ep->lock, flags);  
  85.     kmem_cache_free(epi_cache, epi);  
  86.     return error;  
  87. }  

init_poll_funcptr函数注册poll table回调函数。然后程序的下一步是调用tfile的poll函数,并且poll函数的第2个参数为poll table,

这是epoll机制中唯一对监听套接字调用poll时第2个参数不为NULL的时机。ep_ptable_queue_proc函数的作用是注册等待函数

并添加到指定的等待队列,所以在第一次调用后,该信息已经存在了,无需在poll函数中再次调用了。

[c-sharp]  view plain copy
  1. /* 
  2.  * This is the callback that is used to add our wait queue to the 
  3.  * target file wakeup lists. 
  4.  */  
  5. static void ep_ptable_queue_proc(struct file *file, wait_queue_head_t *whead,  
  6.                  poll_table *pt)  
  7. {  
  8.     struct epitem *epi = ep_item_from_epqueue(pt);  
  9.     struct eppoll_entry *pwq;  
  10.     if (epi->nwait >= 0 && (pwq = kmem_cache_alloc(pwq_cache, GFP_KERNEL))) {  
  11.                 /* 为监听套接字注册一个等待回调函数,在唤醒时调用*/  
  12.         init_waitqueue_func_entry(&pwq->wait, ep_poll_callback);  
  13.         pwq->whead = whead;  
  14.         pwq->base = epi;  
  15.         add_wait_queue(whead, &pwq->wait);  
  16.         list_add_tail(&pwq->llink, &epi->pwqlist);  
  17.         epi->nwait++;  
  18.     } else {  
  19.         /* We have to signal that an error occurred */  
  20.         epi->nwait = -1;  
  21.     }  
  22. }  

 

那么该poll函数到底是怎样的呢,这就要看我们在传入到epoll_ctl前创建的套接字的类型(socket调用)。对于创建的tcp套接字

来说,可以按照创建流程找到其对应得函数是tcp_poll。

tcp_poll的主要功能为:

  1. 如果poll table回调函数存在(ep_ptable_queue_proc),则调用它来等待。注意这只限第一次调用,在后面的poll中都无需此步
  2. 判断事件的到达。(根据tcp的相关成员)

tcp_poll注册到的等待队列是sock成员的sk_sleep,等待队列在对应的IO事件中被唤醒。当等待队列被唤醒时会调用相应的等待回调函数

,前面看到我们注册的是函数ep_poll_callback。该函数可能在中断上下文中调用。

[c-sharp]  view plain copy
  1. /* 
  2.  * This is the callback that is passed to the wait queue wakeup 
  3.  * machanism. It is called by the stored file descriptors when they 
  4.  * have events to report. 
  5.  */  
  6. static int ep_poll_callback(wait_queue_t *wait, unsigned mode, int sync, void *key)  
  7. {  
  8.     int pwake = 0;  
  9.     unsigned long flags;  
  10.     struct epitem *epi = ep_item_from_wait(wait);  
  11.     struct eventpoll *ep = epi->ep;  
  12.     DNPRINTK(3, (KERN_INFO "[%p] eventpoll: poll_callback(%p) epi=%p ep=%p/n",  
  13.              current, epi->ffd.file, epi, ep));  
  14.         /* 对eventpoll的spinlock加锁,因为是在中断上下文中*/  
  15.     spin_lock_irqsave(&ep->lock, flags);  
  16.     /* 没有事件到来 
  17.      * If the event mask does not contain any poll(2) event, we consider the 
  18.      * descriptor to be disabled. This condition is likely the effect of the 
  19.      * EPOLLONESHOT bit that disables the descriptor when an event is received, 
  20.      * until the next EPOLL_CTL_MOD will be issued. 
  21.      */  
  22.     if (!(epi->event.events & ~EP_PRIVATE_BITS))  
  23.         goto out_unlock;  
  24.     /* 
  25.      * If we are trasfering events to userspace, we can hold no locks 
  26.      * (because we're accessing user memory, and because of linux f_op->poll() 
  27.      * semantics). All the events that happens during that period of time are 
  28.      * chained in ep->ovflist and requeued later on. 
  29.      */  
  30.     if (unlikely(ep->ovflist != EP_UNACTIVE_PTR)) {  
  31.         if (epi->next == EP_UNACTIVE_PTR) {  
  32.             epi->next = ep->ovflist;  
  33.             ep->ovflist = epi;  
  34.         }  
  35.         goto out_unlock;  
  36.     }  
  37.     /* If this file is already in the ready list we exit soon */  
  38.     if (ep_is_linked(&epi->rdllink))  
  39.         goto is_linked;  
  40.         /* 加入ready queue*/  
  41.     list_add_tail(&epi->rdllink, &ep->rdllist);  
  42. is_linked:  
  43.     /* 
  44.      * Wake up ( if active ) both the eventpoll wait list and the ->poll() 
  45.      * wait list. 
  46.      */  
  47.     if (waitqueue_active(&ep->wq))  
  48.         wake_up_locked(&ep->wq);  
  49.     if (waitqueue_active(&ep->poll_wait))  
  50.         pwake++;  
  51. out_unlock:  
  52.     spin_unlock_irqrestore(&ep->lock, flags);  
  53.     /* We have to call this outside the lock */  
  54.     if (pwake)  
  55.         ep_poll_safewake(&psw, &ep->poll_wait);  
  56.     return 1;  
  57. }  

 

注意这里有2中队列,一种是在epoll_wait调用中使用的eventpoll的等待队列,用于判断是否有监听套接字可用,一种是对应于每个套接字

的等待队列sk_sleep,用于判断每个监听套接字上事件,该队列唤醒后调用ep_poll_callback,在该函数中又调用wakeup函数来唤醒前一种

队列,来通知epoll_wait调用进程。

[cpp]  view plain copy
  1. static int ep_poll(struct eventpoll *ep, struct epoll_event __user *events,  
  2.            int maxevents, long timeout)  
  3. {  
  4.     int res, eavail;  
  5.     unsigned long flags;  
  6.     long jtimeout;  
  7.     wait_queue_t wait;  
  8.     /* 
  9.      * Calculate the timeout by checking for the "infinite" value ( -1 ) 
  10.      * and the overflow condition. The passed timeout is in milliseconds, 
  11.      * that why (t * HZ) / 1000. 
  12.      */  
  13.     jtimeout = (timeout < 0 || timeout >= EP_MAX_MSTIMEO) ?  
  14.         MAX_SCHEDULE_TIMEOUT : (timeout * HZ + 999) / 1000;  
  15. retry:  
  16.     spin_lock_irqsave(&ep->lock, flags);  
  17.     res = 0;  
  18.     if (list_empty(&ep->rdllist)) {  
  19.         /* 
  20.          * We don't have any available event to return to the caller. 
  21.          * We need to sleep here, and we will be wake up by 
  22.          * ep_poll_callback() when events will become available. 
  23.          */  
  24.         init_waitqueue_entry(&wait, current);  
  25.         wait.flags |= WQ_FLAG_EXCLUSIVE;  
  26.         __add_wait_queue(&ep->wq, &wait);  
  27.         for (;;) {  
  28.             /* 
  29.              * We don't want to sleep if the ep_poll_callback() sends us 
  30.              * a wakeup in between. That's why we set the task state 
  31.              * to TASK_INTERRUPTIBLE before doing the checks. 
  32.              */  
  33.             set_current_state(TASK_INTERRUPTIBLE);  
  34.             if (!list_empty(&ep->rdllist) || !jtimeout)  
  35.                 break;  
  36.             if (signal_pending(current)) {  
  37.                 res = -EINTR;  
  38.                 break;  
  39.             }  
  40.             spin_unlock_irqrestore(&ep->lock, flags);  
  41.             jtimeout = schedule_timeout(jtimeout);  
  42.             spin_lock_irqsave(&ep->lock, flags);  
  43.         }  
  44.         __remove_wait_queue(&ep->wq, &wait);  
  45.         set_current_state(TASK_RUNNING);  
  46.     }  
  47.     /* Is it worth to try to dig for events ? */  
  48.     eavail = !list_empty(&ep->rdllist);  
  49.     spin_unlock_irqrestore(&ep->lock, flags);  
  50.     /* 
  51.      * Try to transfer events to user space. In case we get 0 events and 
  52.      * there's still timeout left over, we go trying again in search of 
  53.      * more luck. 
  54.      */  
  55.     if (!res && eavail &&  
  56.         !(res = ep_send_events(ep, events, maxevents)) && jtimeout)  
  57.         goto retry;  
  58.     return res;  
  59. }  

该函数是在epoll_wait中调用的等待函数,其等待被ep_poll_callback唤醒,然后调用ep_send_events来把到达事件copy到用户空间,然后

epoll_wait才返回。

 

最后我们来看看ep_poll_callback函数和ep_send_events函数的同步,因为他们都要操作ready queue。

eventpoll中巧妙地设置了2种类型的锁,一个是mtx,是个mutex类型,是对该描述符操作的基本同步锁,可以睡眠;所以又存在了另外一个

锁,lock,它是一个spinlock类型,不允许睡眠,所以用在ep_poll_callback中,注意mtx不能用于此。

注意由于ep_poll_callback函数中会涉及到对eventpoll的ovflist和rdllist成员的访问,所以在任意其它地方要访问时都要先加mxt,在加lock锁。

 

由于中断的到来时异步的,为了方便,先看ep_send_events函数。

[cpp]  view plain copy
  1. static int ep_send_events(struct eventpoll *ep, struct epoll_event __user *events,  
  2.               int maxevents)  
  3. {  
  4.     int eventcnt, error = -EFAULT, pwake = 0;  
  5.     unsigned int revents;  
  6.     unsigned long flags;  
  7.     struct epitem *epi, *nepi;  
  8.     struct list_head txlist;  
  9.     INIT_LIST_HEAD(&txlist);  
  10.     /* 
  11.      * We need to lock this because we could be hit by 
  12.      * eventpoll_release_file() and epoll_ctl(EPOLL_CTL_DEL). 
  13.      */  
  14.     mutex_lock(&ep->mtx);  
  15.     /* 
  16.      * Steal the ready list, and re-init the original one to the 
  17.      * empty list. Also, set ep->ovflist to NULL so that events 
  18.      * happening while looping w/out locks, are not lost. We cannot 
  19.      * have the poll callback to queue directly on ep->rdllist, 
  20.      * because we are doing it in the loop below, in a lockless way. 
  21.      */  
  22.     spin_lock_irqsave(&ep->lock, flags);  
  23.     list_splice(&ep->rdllist, &txlist);  
  24.     INIT_LIST_HEAD(&ep->rdllist);  
  25.     ep->ovflist = NULL;  
  26.     spin_unlock_irqrestore(&ep->lock, flags);  
  27.     /* 
  28.      * We can loop without lock because this is a task private list. 
  29.      * We just splice'd out the ep->rdllist in ep_collect_ready_items(). 
  30.      * Items cannot vanish during the loop because we are holding "mtx". 
  31.      */  
  32.     for (eventcnt = 0; !list_empty(&txlist) && eventcnt < maxevents;) {  
  33.         epi = list_first_entry(&txlist, struct epitem, rdllink);  
  34.         list_del_init(&epi->rdllink);  
  35.         /* 
  36.          * Get the ready file event set. We can safely use the file 
  37.          * because we are holding the "mtx" and this will guarantee 
  38.          * that both the file and the item will not vanish. 
  39.          */  
  40.         revents = epi->ffd.file->f_op->poll(epi->ffd.file, NULL);  
  41.         revents &= epi->event.events;  
  42.         /* 
  43.          * Is the event mask intersect the caller-requested one, 
  44.          * deliver the event to userspace. Again, we are holding 
  45.          * "mtx", so no operations coming from userspace can change 
  46.          * the item. 
  47.          */  
  48.         if (revents) {  
  49.             if (__put_user(revents,  
  50.                        &events[eventcnt].events) ||  
  51.                 __put_user(epi->event.data,  
  52.                        &events[eventcnt].data))  
  53.                 goto errxit;  
  54.             if (epi->event.events & EPOLLONESHOT)  
  55.                 epi->event.events &= EP_PRIVATE_BITS;  
  56.             eventcnt++;  
  57.         }  
  58.         /* 
  59.          * At this point, noone can insert into ep->rdllist besides 
  60.          * us. The epoll_ctl() callers are locked out by us holding 
  61.          * "mtx" and the poll callback will queue them in ep->ovflist. 
  62.          */  
  63.         if (!(epi->event.events & EPOLLET) &&  
  64.             (revents & epi->event.events))  
  65.             list_add_tail(&epi->rdllink, &ep->rdllist);  
  66.     }  
  67.     error = 0;  
  68. errxit:  
  69.     spin_lock_irqsave(&ep->lock, flags);  
  70.     /* 
  71.      * During the time we spent in the loop above, some other events 
  72.      * might have been queued by the poll callback. We re-insert them 
  73.      * inside the main ready-list here. 
  74.      */  
  75.     for (nepi = ep->ovflist; (epi = nepi) != NULL;  
  76.          nepi = epi->next, epi->next = EP_UNACTIVE_PTR) {  
  77.         /* 
  78.          * If the above loop quit with errors, the epoll item might still 
  79.          * be linked to "txlist", and the list_splice() done below will 
  80.          * take care of those cases. 
  81.          */  
  82.         if (!ep_is_linked(&epi->rdllink))  
  83.             list_add_tail(&epi->rdllink, &ep->rdllist);  
  84.     }  
  85.     /* 
  86.      * We need to set back ep->ovflist to EP_UNACTIVE_PTR, so that after 
  87.      * releasing the lock, events will be queued in the normal way inside 
  88.      * ep->rdllist. 
  89.      */  
  90.     ep->ovflist = EP_UNACTIVE_PTR;  
  91.     /* 
  92.      * In case of error in the event-send loop, or in case the number of 
  93.      * ready events exceeds the userspace limit, we need to splice the 
  94.      * "txlist" back inside ep->rdllist. 
  95.      */  
  96.     list_splice(&txlist, &ep->rdllist);  
  97.     if (!list_empty(&ep->rdllist)) {  
  98.         /* 
  99.          * Wake up (if active) both the eventpoll wait list and the ->poll() 
  100.          * wait list (delayed after we release the lock). 
  101.          */  
  102.         if (waitqueue_active(&ep->wq))  
  103.             wake_up_locked(&ep->wq);  
  104.         if (waitqueue_active(&ep->poll_wait))  
  105.             pwake++;  
  106.     }  
  107.     spin_unlock_irqrestore(&ep->lock, flags);  
  108.     mutex_unlock(&ep->mtx);  
  109.     /* We have to call this outside the lock */  
  110.     if (pwake)  
  111.         ep_poll_safewake(&psw, &ep->poll_wait);  
  112.     return eventcnt == 0 ? error: eventcnt;  
  113. }  

该函数的注释也很清晰,不过我们从总体上分析下。

 

首先函数加mtx锁,这时必须的。

然后得工作是要读取ready queue,但是中断会写这个成员,所以要加spinlock;但是接下来的工作会sleep,所以在整个loop都加spinlock显然

会阻塞ep_poll_callback函数,从而阻塞中断,这是个很不好的行为,也不可取。于是epoll中在eventpoll中设置了另一个成员ovflist。在读取ready

queue前,我们设置该成员为NULL,然后就可以释放spinlock了。为什么这样可行呢,因为对应的,在ep_poll_callback中,获取spinlock后,对于

到达的事件并不总是放入ready queue,而是先判断ovflist是否为EP_UNACTIVE_PTR。

[cpp]  view plain copy
  1. if (unlikely(ep->ovflist != EP_UNACTIVE_PTR)) {  
  2. /* 进入此处说明用用户进程在调用ep_poll_callback,所以把事件加入ovflist中,而不是ready queue中*/  
  3.         if (epi->next == EP_UNACTIVE_PTR) {/* 如果此处条件不成立,说明该epi已经在ovflist中,所以直接返回*/  
  4.             epi->next = ep->ovflist;  
  5.             ep->ovflist = epi;  
  6.         }  
  7.         goto out_unlock;  
  8.     }  

 

所以在此期间,到达的事件放入了ovflist中。当loop结束后,函数接着遍历该list,添加到ready queue中,最后设置ovflist为EP_UNACTIVE_PTR,

这样下次中断中的事件可以放入ready queue了。最后判断是否有其他epoll_wait调用被阻塞,则唤醒。

 

 

 

从源代码中,可以看出epoll的几大优点:

  1. 用户传入的信息保存在内核中了,无需每次传入
  2. 事件监听机制不在是 整个监听队列,而是每个监听套接字在有事件到达时通过等待回调函数异步通知epoll,然后再返回给用户。

同时epoll中的同步机制也是一个内核编程的设计经典,值得深入理解。


epoll描述

作者:wallwind 发表于2013-5-27 20:19:36  原文链接
阅读:264 评论:0  查看评论
 
[转]服务器端开发的一些建议
摘要: 本文作为游戏服务器端开发的基本大纲,是游戏实践开发中的总结。第一部分专业基础,用于指导招聘和实习考核, 第二部分游戏入门,讲述游戏服务器端开发的基本要点,第三部分服务端架构,介绍架构设计中的一些基本原则。希望能帮到大家

一 专业基础

1.1 网络

1.1.1 理解TCP/IP协议
网络传输模型
滑动窗口技术
建立连接的三次握手与断开连接的四次握手
连接建立与断开过程中的各种状态
TCP/IP协议的传输效率
思考
1)请解释DOS攻击与DRDOS攻击的基本原理
2)一个100Byte数据包,精简到50Byte, 其传输效率提高了50%
3)TIMEWAIT状态怎么解释?
1.1.2 掌握常用的网络通信模型
Select
Epoll,边缘触发与平台出发点区别与应用
Select与Epoll的区别及应用
1.2 存储
计算机系统存储体系
程序运行时的内存结构
计算机文件系统,页表结构
内存池与对象池的实现原理,应用场景与区别
关系数据库MySQL的使用
共享内存
1.3 程序
对C/C++语言有较深的理解
深刻理解接口,封装与多态,并且有实践经验
深刻理解常用的数据结构:数组,链表,二叉树,哈希表
熟悉常用的算法及相关复杂度:冒泡排序,快速排序

二 游戏开发入门

2.1防御式编程
不要相信客户端数据,一定要检验。作为服务器端你无法确定你的客户端是谁,你也不能假定它是善意的,请做好自我保护。(这是判断一个服务器端程序员是否入门的基本标准)
务必对于函数的传人参数和返回值进行合法性判断,内部子系统,功能模块之间不要太过信任,要求低耦合,高内聚
插件式的模块设计,模块功能的健壮性应该是内建的,尽量减少模块间耦合
2.2 设计模式
道法自然。不要迷信,迷恋设计模式,更不要生搬硬套
简化,简化,再简化,用最简单的办法解决问题
借大宝一句话:设计本天成,妙手偶得之
2.3 网络模型
自造轮子: Select, Epoll, Epoll一定比Select高效吗?
开源框架: Libevent, libev, ACE
2.4 数据持久化
自定义文件存储,如《梦幻西游》
关系数据库: MySQL
NO-SQL数据库: MongoDB
选择存储系统要考虑到因素:稳定性,性能,可扩展性
2.5 内存管理
使用内存池和对象池,禁止运行期间动态分配内存
对于输入输出的指针参数,严格检查,宁滥勿缺
写内存保护。使用带内存保护的函数(strncpy, memcpy, snprintf, vsnprintf等),严防数组下标越界
防止读内存溢出,确保字符串以’\0’结束
2.6 日志系统
简单高效,大量日志操作不应该影响程序性能
稳定,做到服务器崩溃是日志不丢失
完备,玩家关键操作一定要记日志,理想的情况是通过日志能重建任何时刻的玩家数据
开关,开发日志的要加级别开关控制
2.7 通信协议
采用PDL(Protocol Design Language), 如Protobuf,可以同时生成前后端代码,减少前后端协议联调成本, 扩展性好
JSON,文本协议,简单,自解释,无联调成本,扩展性好,也很方便进行包过滤以及写日志
自定义二进制协议,精简,有高效的传输性能,完全可控,几乎无扩展性
2.8 全局唯一Key(GUID)
为合服做准备
方便追踪道具,装备流向
每个角色,装备,道具都应对应有全局唯一Key
2.9 多线程与同步
消息队列进行同步化处理
2.10 状态机
强化角色的状态
前置状态的检查校验
2.11 数据包操作
合并, 同一帧内的数据包进行合并,减少IO操作次数
单副本, 用一个包尽量只保存一份,减少内存复制次数
AOI同步中减少中间过程无用数据包
2.12 状态监控
随时监控服务器内部状态
内存池,对象池使用情况
帧处理时间
网络IO
包处理性能
各种业务逻辑的处理次数
2.13 包频率控制
基于每个玩家每条协议的包频率控制,瘫痪变速齿轮
2.14 开关控制
每个模块都有开关,可以紧急关闭任何出问题的功能模块
2.15 反外挂反作弊
包频率控制可以消灭变速齿轮
包id自增校验,可以消灭WPE
包校验码可以消灭包拦截篡改
图形识别吗,可以踢掉99%非人的操作
魔高一尺,道高一丈
2.16 热更新
核心配置逻辑的热更新,如防沉迷系统,包频率控制,开关控制等
代码基本热更新,如Erlang,Lua等
2.17 防刷
关键系统资源(如元宝,精力值,道具,装备等)的产出记日志
资源的产出和消耗尽量依赖两个或以上的独立条件的检测
严格检查各项操作的前置条件
校验参数合法性
2.18 防崩溃
系统底层与具体业务逻辑无关,可以用大量的机器人压力测试暴露各种bug,确保稳定
业务逻辑建议使用脚本
系统性的保证游戏不会崩溃
2.19 性能优化
IO操作异步化
IO操作合并缓写 (事务性的提交db操作,包合并,文件日志缓写)
Cache机制
减少竞态条件 (避免频繁进出切换,尽量减少锁定使用,多线程不一定由于单线程) 多线程不一定比单线程快
减少内存复制
自己测试,用数据说话,别猜
2.20 运营支持
接口支持:实时查询,控制指令,数据监控,客服处理等
实现考虑提供Http接口
2.21 容灾与故障预案

三 服务器端架构

3.1 什么是好的架构?
满足业务要求
能迅速的实现策划需求,响应需求变更
系统级的稳定性保障
简化开发。将复杂性控制在架构底层,降低对开发人员的技术要求,逻辑开发不依赖于开发人员本身强大的技术实力,提高开发效率
完善的运营支撑体系
3.2 架构实践的思考
简单,满足需求的架构就是好架构
设计性能,抓住重要的20%, 没必要从程序代码里面去抠性能
热更新是必须的
人难免会犯错,尽可能的用一套机制去保障逻辑的健壮性

游戏服务器的设计是一项颇有挑战性的工作,游戏服务器的发展也由以前的单服结构转变为多服机构,甚至出现了bigworld引擎的分布式解决方案,最近了解到Unreal的服务器解决方案atlas也是基于集群的方式。

负载均衡是一个很复杂的课题,这里暂不谈bigworld和atlas的这类服务器的设计,更多的是基于功能和场景划分服务器结构。

首先说一下思路,服务器划分基于以下原则:

  1. 分离游戏中占用系统资源(cpu,内存,IO等)较多的功能,独立成服务器。
  2. 在同一服务器架构下的不同游戏,应尽可能的复用某些服务器(进程级别的复用)。
  3. 以多线程并发的编程方式适应多核处理器。
  4. 宁可在服务器之间多复制数据,也要保持清晰的数据流向。
  5. 主要按照场景划分进程,若需按功能划分,必须保持整个逻辑足够的简单,并满足以上1,2点。

服务器结构图:

游戏服务器架构拓扑图

各个服务器的简要说明:

Gateway 是应用网关,主要用于保持和client的连接,该服务器需要2种IO,对client采用高并发连接,低吞吐量的网络模型,如IOCP等,对服务器采用高吞吐量连接,如阻塞或异步IO。

网关主要有以下用途:

  1. 分担了网络IO资源
  2. 同时,也分担了网络消息包的加解密,压缩解压等cpu密集的操作。
  3. 隔离了client和内部服务器组,对client来说,它只需要知道网关的相关信息即可(ip和port)。
  4. client由于一直和网关保持常连接,所以切换场景服务器等操作对client来说是透明的。
  5. 维护玩家登录状态。

World Server 是一个控制中心,它负责把各种计算资源分布到各个服务器,它具有以下职责:

  1. 管理和维护多个Scene Server。
  2. 管理和维护多个功能服务器,主要是同步数据到功能服务器。
  3. 复杂转发其他服务器和Gateway之间的数据。
  4. 实现其他需要跨场景的功能,如组队,聊天,帮派等。

Phys Server 主要用于玩家移动,碰撞等检测。

所有玩家的移动类操作都在该服务器上做检查,所以该服务器本身具备所有地图的地形等相关信息。具体检查过程是这样的:首先,Worldserver收到一个移动信息,WorldServer收到后向Phys Server请求检查,Phys Server检查成功后再返回给world Server,然后world server传递给相应的Scene Server。

Scene Server 场景服务器,按场景划分,每个服务器负责的场景应该是可以配置的。理想情况下是可以动态调节的。

ItemMgr Server 物品管理服务器,负责所有物品的生产过程。在该服务器上存储一个物品掉落数据库,服务器初始化的时候载入到内存。任何需要产生物品的服务器均与该服务器直接通信。

AIServer 又一个功能服务器,负责管理所有NPC的AI。AI服务器通常有2个输入,一个是Scene Server发送过来的玩家相关操作信息,另一个时钟Timer驱动,在这个设计中,对其他服务器来说,AIServer就是一个拥有很多个NPC的客户端。AIserver需要同步所有与AI相关的数据,包括很多玩家数据。由于AIServer的Timer驱动特性,可在很大程度上使用TBB程序库来发挥多核的性能。

把网络游戏服务器分拆成多个进程,分开部署。这种设计的好处是模块自然分离,可以单独设计。分担负荷,可以提高整个系统的承载能力。

缺点在于,网络环境并不那么可靠。跨进程通讯有一定的不可预知性。服务器间通讯往往难以架设调试环境,并很容易把事情搅成一团糨糊。而且正确高效的管理多连接,对程序员来说也是一项挑战。

前些年,我也曾写过好几篇与之相关的设计。这几天在思考一个问题:如果我们要做一个底层通用模块,让后续开发更为方便。到底要解决怎样的需求。这个需求应该是单一且基础的,每个应用都需要的。

正如 TCP 协议解决了互联网上稳定可靠的点对点数据流通讯一样。游戏世界实际需要的是一个稳定可靠的在游戏系统内的点对点通讯需要。

我们可以在一条 TCP 连接之上做到这一点。一旦实现,可以给游戏服务的开发带来极大的方便。

可以把游戏系统内的各项服务,包括并不限于登陆,拍卖,战斗场景,数据服务,等等独立服务看成网络上的若干终端。每个玩家也可以是一个独立终端。它们一起构成一个网络。在这个网络之上,终端之间可以进行可靠的连接和通讯。

实现可以是这样的:每个虚拟终端都在游戏虚拟网络(Game Network)上有一个唯一地址 (Game Network Address , GNA) 。这个地址可以预先设定,也可以动态分配。每个终端都可以通过游戏网络的若干接入点 ( GNAP ) 通过唯一一条 TCP 连接接入网络。接入过程需要通过鉴权。

鉴权过程依赖内部的安全机制,可以包括密码证书,或是特别的接入点区分。(例如,玩家接入网络就需要特定的接入点,这个接入点接入的终端都一定是玩家)

鉴权通过后,网络为终端分配一个固定的游戏域名。例如,玩家进入会分配到 player.12345 这样的域名,数据库接入可能分配到 database 。

游戏网络默认提供一个域名查询服务(这个服务可以通过鉴权的过程注册到网络中),让每个终端都能通过域名查询到对应的地址。

然后,游戏网络里所有合法接入的终端都可以通过其地址相互发起连接并通讯了。整个协议建立在 TCP 协议之上,工作于唯一的这个 TCP 连接上。和直接使用 TCP 连接不同。游戏网络中每个终端之间相互发起连接都是可靠的。不仅玩家可以向某个服务发起连接,反过来也是可以的。玩家之间的直接连接也是可行的(是否允许这样,取决于具体设计)。

由于每个虚拟连接都是建立在单一的 TCP 连接之上。所以减少了互连网上发起 TCP 连接的各种不可靠性。鉴权过程也是一次性唯一的。并且我们提供域名反查服务,我们的游戏服务可以清楚且安全的知道连接过来的是谁。

系统可以设计为,游戏网络上每个终端离网,域名服务将广播这条消息,通知所有人。这种广播服务在互联网上难以做到,但无论是广播还是组播,在这个虚拟游戏网络中都是可行的。

在这种设计上。在逻辑层面,我们可以让玩家直接把聊天信息从玩家客互端发送到聊天服务器,而不需要建立多余的 TCP 连接,也不需要对转发处理聊天消息做多余的处理。聊天服务器可以独立的存在于游戏网络。也可以让广播服务主动向玩家推送消息,由服务器向玩家发起连接,而不是所有连接请求都是由玩家客互端发起。

虚拟游戏网络的构成是一个独立的层次,完全可以撇开具体游戏逻辑来实现,并能够单独去按承载量考虑具体设计方案。非常利于剥离出具体游戏项目来开发并优化。

最终,我们或许需要的一套 C 库,用于游戏网络内的通讯。api 可以和 socket api 类似。额外多两条接入与离开游戏网络即可。

作者:wallwind 发表于2013-5-27 19:08:17  原文链接
阅读:476 评论:0  查看评论
 
[转]epoll基本模型案例实现

 这两天在看项目的数据结构定义及关系,遇到一些关于socket的知识点,还有一些C++的知识点,下面总结下:

1. struct epoll_event

    结构体epoll_event 被用于注册所感兴趣的事件和回传所发生待处理的事件,定义如下:

    typedef union epoll_data {
        void *ptr;
         int fd;
         __uint32_t u32;
         __uint64_t u64;
     } epoll_data_t;//保存触发事件的某个文件描述符相关的数据

     struct epoll_event {
         __uint32_t events;      /* epoll event */
         epoll_data_t data;      /* User data variable */
     };

    其中events表示感兴趣的事件和被触发的事件,可能的取值为:
    EPOLLIN :表示对应的文件描述符可以读;
    EPOLLOUT:表示对应的文件描述符可以写;
    EPOLLPRI: 表示对应的文件描述符有紧急的数可读;

    EPOLLERR:表示对应的文件描述符发生错误;
    EPOLLHUP:表示对应的文件描述符被挂断;
    EPOLLET:    ET的epoll工作模式;

    所涉及到的函数有:

1、epoll_create函数
     函数声明:int epoll_create(int size)
    功能:该函数生成一个epoll专用的文件描述符,其中的参数是指定生成描述符的最大范围;


2、epoll_ctl函数
     函数声明:int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event)
     功能:用于控制某个文件描述符上的事件,可以注册事件,修改事件,删除事件。
    @epfd:由 epoll_create 生成的epoll专用的文件描述符;
     @op:要进行的操作,EPOLL_CTL_ADD 注册、EPOLL_CTL_MOD 修改、EPOLL_CTL_DEL 删除;
     @fd:关联的文件描述符;
    @event:指向epoll_event的指针;
    成功:0;失败:-1


3、epoll_wait函数
    函数声明:int epoll_wait(int epfd,struct epoll_event * events,int maxevents,int timeout)
    功能:该函数用于轮询I/O事件的发生;
    @epfd:由epoll_create 生成的epoll专用的文件描述符;
    @epoll_event:用于回传代处理事件的数组;
    @maxevents:每次能处理的事件数;
    @timeout:等待I/O事件发生的超时值;
    成功:返回发生的事件数;失败:-1

应用举例:

#define SERV_PORT 4466   //服务端口号
const char *LOCAL_ADDR = "127.0.0.1";//绑定服务地址

bool setnonblocking(int sock)//设置socket为非阻塞方式
{
    int opts;
    
    opts=fcntl(sock,F_GETFL);
    if(opts<0)
    {
        perror("fcntl(sock,GETFL)");
        return false;
    }
    opts = opts|O_NONBLOCK;
    if(fcntl(sock,F_SETFL,opts)<0)
    {
        perror("fcntl(sock,SETFL,opts)");
        return false;
    }
    return true;
}

int main()
{
  int i, maxi, listenfd, new_fd, sockfd,epfd,nfds;
  ssize_t n;
  char line[MAXLINE];
  socklen_t clilen;
  struct epoll_event ev,events[20];//ev用于注册事件,数组用于回传要处理的事件
  struct sockaddr_in clientaddr, serveraddr;

  listenfd = socket(AF_INET, SOCK_STREAM, 0);//生成socket文件描述符
  setnonblocking(listenfd);//把socket设置为非阻塞方式
   
  epfd=epoll_create(256);//生成用于处理accept的epoll专用的文件描述符
  ev.data.fd=listenfd;//设置与要处理的事件相关的文件描述符
  ev.events=EPOLLIN|EPOLLET;//设置要处理的事件类型
  epoll_ctl(epfd,EPOLL_CTL_ADD,listenfd,&ev);//注册epoll事件

       //设置服务器端地址信息
  bzero(&serveraddr, sizeof(serveraddr));
  serveraddr.sin_family = AF_INET;
  char *local_addr= LOCAL_ADDR;
  inet_aton(local_addr,&(serveraddr.sin_addr));
  serveraddr.sin_port=htons(SERV_PORT);

  bind(listenfd,(sockaddr *)&serveraddr, sizeof(serveraddr));//绑定socket连接
  listen(listenfd, LISTENQ);//监听

  maxi = 0;
  for ( ; ; ) 
      {
         /* epoll_wait:等待epoll事件的发生,并将发生的sokct fd和事件类型放入到events数组中;
          * nfds:为发生的事件的个数。
          * 注:事件发生后,注册在epfd上的socket fd的事件类型会被清空,所以如果下一个循环你
         * 还要关注这个socket fd的话,则需要用epoll_ctl(epfd,EPOLL_CTL_MOD,listenfd,&ev)来
         * 重新设置socket fd的事件类型
         */
      nfds=epoll_wait(epfd,events,20,500);

      //处理所发生的所有事件
      for(i=0;i<nfds;++i)
      {
          if(events[i].data.fd==listenfd)//事件发生在listenfd上
          {
                    /* 获取发生事件端口信息,存于clientaddr中;
                   * new_fd:返回的新的socket描述符,用它来对该事件进行recv/send操作*/
              new_fd = accept(listenfd,(struct sockaddr *)&clientaddr, &clilen);
              if(connfd<0)
                   {
                  perror("connfd<0");
                  exit(1);
              }
              setnonblocking(connfd);
              char *str = inet_ntoa(clientaddr.sin_addr);
              ev.data.fd=connfd;//设置用于读操作的文件描述符
              ev.events=EPOLLIN|EPOLLET;//设置用于注测的读操作事件
              epoll_ctl(epfd,EPOLL_CTL_ADD,connfd,&ev);//注册ev
          }
          else if(events[i].events&EPOLLIN)
          {
              if ( (sockfd = events[i].data.fd) < 0) 
                       continue;

              if ( (n = read(sockfd, line, MAXLINE)) < 0) 
                   {
                  if (errno == ECONNRESET) 
                      {
                      close(sockfd);
                      events[i].data.fd = -1;
                  }
                      else
                      std::cout<<"readline error"<<std::endl;
              } 
                  else if (n == 0) 
                  {
                  close(sockfd);
                  events[i].data.fd = -1;
             }
             ev.data.fd=sockfd;//设置用于写操作的文件描述符
             ev.events=EPOLLOUT|EPOLLET;//设置用于注测的写操作事件
             epoll_ctl(epfd,EPOLL_CTL_MOD,sockfd,&ev);//修改sockfd上要处理的事件为EPOLLOUT
        }
       else if(events[i].events&EPOLLOUT)
       {
           sockfd = events[i].data.fd;
            write(sockfd, line, n);
  
            ev.data.fd=sockfd;//设置用于读操作的文件描述符
            ev.events=EPOLLIN|EPOLLET;//设置用于注测的读操作事件
            epoll_ctl(epfd,EPOLL_CTL_MOD,sockfd,&ev);//修改sockfd上要处理的事件为EPOLIN
       }
   }
 }
}

作者:wallwind 发表于2013-5-12 23:44:22  原文链接 阅读:3
 
 


  • 0
    点赞
  • 0
    评论
  • 3
    收藏
  • 扫一扫,分享海报

参与评论 您还未登录,请先 登录 后发表或查看评论
©️2022 CSDN 皮肤主题:大白 设计师:CSDN官方博客 返回首页
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、C币套餐、付费专栏及课程。

余额充值