网游服务器端设计思考:心跳设计
网络游戏服务器的主要作用是模拟整个游戏世界,客户端用过网络连接把一些信息数据发给服务器,在操作合法的情况下,更新服务器上该客户端对应的player实体、所在场景等,并把这些操作及其影响广播出去。让别的客户端能显示这些操作。
在这个模拟过程中,需要解决的一个重要问题是:多长时间处理(更新)一次该服务器上的待处理事件,体现在实际开发中,这就是一个服务器端的心跳设计问题(tick)。
在网络游戏服务器端的心跳设计里主要面临以下几个问题:
- 心跳函数的处理间隔往往是固定的,因为需要模拟现实世界中时间的性质,不能让游戏世界表现得忽快忽慢。但处理间隔固定不代表一定要和真实时间一致,有可能有快放慢放的需求。
- 固定间隔的心跳,间隔多少长?50ms,100ms,500ms?
- 由于服务器每次心跳处理的事件数量和复杂度不一样,每次处理所需的时间也会不同,服务器繁忙时和闲置时相差很远,应该使用什么策略来应对?
- 编码实现时应该怎么设计?是和游戏主循环在同一个线程里,还是把心跳写到一个单独的timer线程里,或者干脆做成一台心跳服务器(心跳指令定期通过TCP发出,或者通过同步卡),逻辑服务器都由心跳服务器控制tick的频率。
- 心跳必须和逻辑程序写在一个进程空间里吗?有没有以独立运行的心跳服务?
为了解决以上问题,本文将对心跳进行分类,从不同角度进行讨论。
一、按照策略分类
就心跳间隔策略而言,现在的网游服务器端主要分为两种。分别是固定tick时间和固定sleep时间,可以通过下图进行具体的说明:
图 1-1
如上图1-1中,画出两种间隔策略的示意图,渐变颜色的横条代表时间,Tick1、Tick2代表程序两次不同的更新操作,Run1、Run2代表在心跳函数里处理更新操作所需的时间,Sleep1、Sleep2代表让出CPU时间片的时间。
(1)固定Tick时间:顾名思义就是指程序每次心跳的时间都是等长的、固定的。如图中的“图A”,Tick1和Tick2的时间是相等的,如果实际执行的比上次执行时间长(Run2 > Run1),则Sleep2 < Sleep1,同时满足等式:Tick1 = Tick2 = Run1 + Sleep1 = Run2 + Sleep2
(2)固定Sleep时间:每次心跳,更新操作执行完成后,sleep固定的时间。如图中的“图B”,Sleep1 = Sleep2,Run1和Run2不一定相等,同时满足等式:Tick1 = Run1 + Sleep1,Tick2 = Run2 + Sleep2
下面结合具体的代码对比说明这两种策略
1.1 固定Tick时间
使用固定tick时间的心跳策略的一大好处就是,在负荷不高的情况下,由于相邻两次tick的时间一定,所以开始执行Run1到开始执行Run2的时间间隔一定。tick时间固定带来的另一个好处就是容易实现逻辑服务器运行时快放慢放功能(见??),当然固定tick时间同样带来一些问题,如下图:
图 1-2
如图1-2,在负荷不高的情况下,心跳函数可以按照上图中“图A”的时间线正常的运行,如果在服务器运行的过程中遇到一些突发事件(开新服、做活动、大世界内大范围的帮战),会导致服务器CPU负荷变高,从而使得一次tick无法处理完当前所有事件,出现“图B”中的情况Run1 > Tick1,这时Sleep1不管取什么值都不能满足等式Tick1 = Run1 + Sleep1,
这样一来就带来第一个问题:高负荷情况下如何保证CPU能充分利用的情况下,tick1和tick2两次心跳互相不干扰?伴随而来的另一个问题是tick时间设为多长才能满足低负荷时固定间隔的要求,同时不能经常出现“图B”的情况?
下面结合实例讲解固定tick时间的心跳如何编写,以及如何处理以上两个问题。
Mangos-Zero
mangos-zero项目中的逻辑服务进程mangosd的心跳函数采用如图1-2中的“图C”的方法,当更新的处理时间Run1大于固定大小的tick时间时,下一个tick到来时不sleep直接执行Run2,实现代码如下:
1: /// Heartbeat for the World
2: void WorldRunnable::run()
3: {
4: ///- Init new SQL thread for the world database
5: WorldDatabase.ThreadStart(); // let thread do safe mySQL requests (one connection call enough)
6: sWorld.InitResultQueue();
7:
8: uint32 realCurrTime = 0;
9: uint32 realPrevTime = WorldTimer::tick();
10:
11: uint32 prevSleepTime = 0; // used for balanced full tick time length near WORLD_SLEEP_CONST
12:
13: ///- While we have not World::m_stopEvent, update the world
14: while (!World::IsStopped())
15: {
16: ++World::m_worldLoopCounter;
17: realCurrTime = WorldTimer::getMSTime(); //----------------(1)
18:
19: uint32 diff = WorldTimer::tick(); //--------------(2)
20:
21: sWorld.Update( diff ); //--------------(3)
22: realPrevTime = realCurrTime;
23:
24: // diff (D0) include time of previous sleep (d0) + tick time (t0)
25: // we want that next d1 + t1 == WORLD_SLEEP_CONST
26: // we can't know next t1 and then can use (t0 + d1) == WORLD_SLEEP_CONST requirement
27: // d1 = WORLD_SLEEP_CONST - t0 = WORLD_SLEEP_CONST - (D0 - d0) = WORLD_SLEEP_CONST + d0 - D0
28: if (diff <= WORLD_SLEEP_CONST+prevSleepTime) //----------------(4)
29: {
30: prevSleepTime = WORLD_SLEEP_CONST+prevSleepTime-diff;
31: ACE_Based::Thread::Sleep(prevSleepTime);
32: }
33: else
34: prevSleepTime = 0;
35:
36: #ifdef WIN32
37: if (m_ServiceStatus == 0) World::StopNow(SHUTDOWN_EXIT_CODE);
38: while (m_ServiceStatus == 2) Sleep(1000);
39: #endif
40: }
41:
42: sWorld.KickAll(); // save and kick all players
43: sWorld.UpdateSessions( 1 ); // real players unload required UpdateSessions call
44:
45: // unload battleground templates before different singletons destroyed
46: sBattleGroundMgr.DeleteAllBattleGrounds();
47:
48: sWorldSocketMgr->StopNetwork();
49:
50: sMapMgr.UnloadAll(); // unload all grids (including locked in memory)
51:
52: ///- End the database thread
53: WorldDatabase.ThreadEnd(); // free mySQL thread resources
54: }
以上代码是游戏世界的主循环,看while循环里的代码,主要干下面几件事:
(1)从WorldTimer::getMSTime()得到一个uint32的值realCurrTime,realCurrTime是循环的(到增加到0xFFFFFFFF后,在增加就变成0),表示当前时间,单位是毫秒,是一个相对前一次tick的时间。
(2)使用WorldTimer::tick();计算上次tick到这次tick的时间差diff,该值理论上等于realCurrTime – realPrevTime
(3)sWorld.Update( diff );就是tick里的处理函数,游戏逻辑在这里得到更新处理。
(4)这里就是图1-2中的“图C”所描述的,如果运行时间大于固定的tick时间,则不sleep继续占用CPU来处理更新,直到能在一个tick处理所有操作为止,这个时候才会sleep让出CPU时间片。
(5)WORLD_SLEEP_CONST就是固定的tick的时间长度,在这里是50ms
总结:现在可以回答本节前面的两个问题:在高负荷情况下mangos采用图1-2中“图C”的方式提高服务器的响应速度,每个tick时间长度为50ms,也就是每秒钟更新20次,能满足更新的需求。
timer_thread
出于模块化的考虑,固定tick时间策略还有一种实现方式:使用单独的线程做timer,周期性产生心跳信号或者心跳task。工作原理如下图:
图 1-3
图1-3也是比较常见的设计方案,服务器进程采用多线程方式,主循环线程、timer线程及其他非工作线程向任务队列(task queue)中添加task,而工作线程不断的从任务队列中取出任务执行相应的处理。这里提到的timer thread就是用来产生心跳任务的,timer thread会每隔50ms产生一个heartbeat task放入任务队列中。一般来说队列中的heartbeat task的数量会远远大于其他task,所以这种策略也可以称为固定tick时间的心跳策略。在服务器高负荷运行的情况下,近似于mangos所采用的图1-2中“图C”的方式进行处理。多个worker thread的情况下,还需要对heartbeat task加锁。
1.2 固定Sleep时间
固定Sleep也是一种比较常见的心跳函数间隔处理策略,如下图,每次心跳处理函数执行完毕后sleep固定长度时间。
图 1-4
如图1-4,Sleep1 = Sleep2,Run1和Run2不一定相等,同时满足等式:Tick1 = Run1 + Sleep1,Tick2 = Run2 + Sleep2。下面结合实例进行说明:
天龙
根据网上流出的天龙源代码,GameServer工程的主循环至线程的心跳函数的调用过程如下:
1: BOOL Server::Loop( )
2: {
3: ........
4:
5: ret = g_pThreadManager->Start( ) ; //--------(1)
6:
7: ........
8:
9: return TRUE ;
10: }
11: |
12: |
13: \|/
14: BOOL ThreadManager::Start( )
15: {
16: ........
17:
18: BOOL ret ;
19: m_pServerThread->start() ; //--------(2)
20: MySleep( 500 ) ;
21: ret = m_pThreadPool->Start( ) ;
22:
23: ........
24: }
25: |
26: |
27: \|/
28: VOID Thread::start ()
29: {
30: ........
31:
32: #if defined(__LINUX__)
33: pthread_create( &m_TID, NULL , MyThreadProcess , this );
34: #elif defined(__WINDOWS__)
35: m_hThread = ::CreateThread( NULL, 0, MyThreadProcess , this, 0, &m_TID ) ;
36: #endif
37:
38: ........
39: }
40: |
41: |
42: \|/
43: VOID ServerThread::run( )
44: {
45: ........
46:
47: _MY_TRY
48: {
49: g_pServerManager->m_ThreadID = getTID() ;
50:
51: while( IsActive() )
52: {
53: if( g_pServerManager )
54: {
55: BOOL ret = g_pServerManager->Tick( ) ; //--------(3)
56: Assert( ret ) ;
57: }
58:
59: ........
60: }
61: }
62: _MY_CATCH
63: {
64: ........
65: }
66:
67: ........
68: }
如上,主循环启动一个“用来处理服务器之间数据通讯的线程”m_pServerThread,以及一个线程池m_pThreadPool。首先看m_pServerThread的run ()函数,调用g_pServerManager->tick ()函数,代码如下:
1: BOOL ServerManager::Tick( )
2: {
3: ........
4:
5: BOOL ret ;
6:
7: _MY_TRY
8: {
9: ret = Select( ) ; //--------(1)
10: Assert( ret ) ;
11: }
12: _MY_CATCH
13: {
14: SaveCodeLog( ) ;
15: }
16:
17: ........
18: }
19: |
20: |
21: \|/
22: BOOL ServerManager::Select( )
23: {
24: __ENTER_FUNCTION
25:
26: MySleep(50) ; //--------(2)
27: if( m_MaxFD==INVALID_SOCKET && m_MinFD==INVALID_SOCKET )
28: {
29: return TRUE ;
30: }
31:
32: m_Timeout[SELECT_USE].tv_sec = m_Timeout[SELECT_BAK].tv_sec;
33: m_Timeout[SELECT_USE].tv_usec = m_Timeout[SELECT_BAK].tv_usec;
34:
35: m_ReadFDs[SELECT_USE] = m_ReadFDs[SELECT_BAK];
36: m_WriteFDs[SELECT_USE] = m_WriteFDs[SELECT_BAK];
37: m_ExceptFDs[SELECT_USE] = m_ExceptFDs[SELECT_BAK];
38:
39: _MY_TRY
40: {
41: INT iRet = SocketAPI::select_ex( (INT)m_MaxFD+1 ,
42: &m_ReadFDs[SELECT_USE] ,
43: &m_WriteFDs[SELECT_USE] ,
44: &m_ExceptFDs[SELECT_USE] ,
45: &m_Timeout[SELECT_USE] ) ; //--------(3)
46: if( iRet==SOCKET_ERROR )
47: {
48: Assert(FALSE) ;
49: }
50: }
51: _MY_CATCH
52: {
53: Log::SaveLog( SERVER_LOGFILE, "ERROR: ServerManager::Select( )..." ) ;
54: }
55:
56: return TRUE ;
57:
58: __LEAVE_FUNCTION
59:
60: return FALSE ;
61:
62: }
如上,在Tick ()函数里首先调用ServerManager::Select (),在Select函数中(2)调用MySleep(50)让出CPU时间片50ms,然后给select_ex设置100us的超时,可以认为每次执行完处理后,会固定sleep 50ms。
再来看看Threadpool里线程的tick函数
1: BOOL Scene::Tick( )
2: {
3: ........
4:
5: //网络处理
6: _MY_TRY
7: {
8: ret = m_pScenePlayerManager->Select( ) ; //--------(1)
9: Assert( ret ) ;
10: }
11: _MY_CATCH
12: {
13: SaveCodeLog( ) ;
14: }
15:
16: ........
17: }
18: |
19: |
20: \|/
21: BOOL ScenePlayerManager::Select( )
22: {
23: {
24: MySleep( 50 ) ; //--------(2)
25: }
26:
27: if( m_MaxFD==INVALID_SOCKET && m_MinFD==INVALID_SOCKET )
28: return TRUE ;
29:
30: m_Timeout[SELECT_USE].tv_sec = m_Timeout[SELECT_BAK].tv_sec;
31: m_Timeout[SELECT_USE].tv_usec = m_Timeout[SELECT_BAK].tv_usec;
32:
33: m_ReadFDs[SELECT_USE] = m_ReadFDs[SELECT_BAK];
34: m_WriteFDs[SELECT_USE] = m_WriteFDs[SELECT_BAK];
35: m_ExceptFDs[SELECT_USE] = m_ExceptFDs[SELECT_BAK];
36:
37: _MY_TRY
38: {
39: INT ret = SocketAPI::select_ex( (INT)m_MaxFD+1 ,
40: &m_ReadFDs[SELECT_USE] ,
41: &m_WriteFDs[SELECT_USE] ,
42: &m_ExceptFDs[SELECT_USE] ,
43: &m_Timeout[SELECT_USE] ) ; //--------(3)
44: if( ret == SOCKET_ERROR )
45: {
46: Assert(FALSE) ;
47: }
48: }
49: _MY_CATCH
50: {
51: SaveCodeLog( ) ;
52: }
53:
54: ........
55: }
根据以上代码中的(1)、(2)、(3)可以看到场景的tick函数中采用同样的方法,每次调用时先调用MySleep(50)让出CPU时间片50ms,然后再执行相应的处理代码。
总结:
(a)固定Sleep时间在高负荷情况下,由于每次都会强制让出CPU时间片,会不会导致响应不及时?
(b)在每次运行执行处理函数的时间比较稳当的情况下,这种策略还是能提供比较稳定的tick间隔。
(c)这样的设计目前为止本人还没有看出有什么好处?请各位博友指教。
二、按照物理位置分类
按照物理位置分类,分为两种,一种是运行在同一物理主机上,另一种是运行在不同的物理主机上。该分类是受到云风一篇blog的启发(见《心跳服务器》)。
(1)心跳服务和逻辑程序运行在同一物理主机上:据我所知,大部分的网游服务器程序都采用这种方式,而且心跳服务往往和逻辑程序写在同一个进程里。心跳服务和逻辑程序不在一个进程里,但运行在同一物理主机上的情况本人没有见过,也不敢妄加臆断。
(2)心跳服务运行在一台独立的物理主机上:(a)按照云风的说法,这样做最大的好处就是方便实现服务器的快放慢放功能,从而很容易重现bug。博文中指出使用使用10Hz的心跳间隔,以局域网内0.1~0.2ms的ping值,不知道能不能容忍?而且使用TCP连接,多两次网络IO,不知道效率如何?(b)另一种独立的心跳服务器是本人读研时参与过的大屏幕显示同步时遇到的,当时一块大屏幕有24台主机,每台主机负责绘制图像中的一块区域。使用一台单独的心跳服务器,用同步卡向着24台主机的串口周期性的发信号,这24台主机收到从串口来的心跳信号后,同时开始绘制这一帧。不过据说精确度有待提高………
三、总结
综上所述,最常见的心跳服务是这样设计的:使用固定tick时间策略,与逻辑处理程序写在同一个进程里,tick时间50ms,100ms。
声明:本文所使用的代码均从互联网上获得,本人没有传播及利用其获取商业利益