??? 在這個模擬過程中,需要解決的一個重要問題是:多長時間處理(更新)一次該服務器上的待處理事件,體現在實際開發中,這就是一個服務器端的心跳設計問題(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。