魔獸時間是暴雪著名的網絡游戲,我以前也玩過一段時間的戰士,這款游戲目前已進入晚年時期,不過里面各種豐富的游戲系統和游戲內容都非常讓人印象深刻。開源的Mangos項目模擬魔獸服務器端非常成功,目前國內外也有不少基於Mangos模擬器而搭建的私服,多數服務端運轉良好,非常穩定。國外有一個叫做MonsterWOW的魔獸私服,單服承載5000人,總共有幾組服務器,幾萬人同時在線,這是我在網站上親眼看到的實時數據,一般來講,如果對MMORPG游戲服務端稍微熟悉都知道,5000人同服在線,而且允許游戲邏輯的是一台單獨的服務器,支撐這么龐大一個游戲世界,肯定有非常過人之處,至少據我所知國內的單服性能與之相比都有較大差距,國內分布式的服務端架構基本也是將游戲邏輯分散到多台服務器上,單一世界承載數量也不算很高。幾年前的Eve Online單一世界可以承載兩萬多玩家同時在線、實時交互。我想國內多數MMORPG服務端的承載人數應該都是在七八百、一兩千這個數量級的。Mangos的源代碼下載下來好久了,一直沒時間研究,它目前是C++寫成的,我的主要方向是C#,不過我一直有將C#做游戲服務端的打算,所以既然它有那么多過人之處,就算不能掌握全部也應該研究學習一下。
今天粗略地看了一下,服務端主要又三大塊組成,數據庫、服務端邏輯、腳本。數據庫用的MySQL,這里不是很關鍵暫且不說,腳本有自己的腳步引擎,簡單的任務、戰斗等都可以通過數據庫配置相應條目來完成,復雜的戰斗AI等在腳步庫中由C++直接寫成,這個腳本庫是要被編譯為機器代碼的,執行效率相當高效,例如巫妖王的戰斗比較復雜就用C++寫,其它簡單的就配置在數據庫中由腳步引擎來驅動執行。國內不少服務端都是非常老式的C++早期服務端結構,不少嵌入了lua解釋器,大量的寫lua腳本,甚至用lua寫邏輯。我個人很不理解這種方式,你說效率高吧,lua再快能多塊,解釋執行和編譯執行不是一個數量級的,看看服務端的承載人數就知道了,lua JIT即時編譯都不靠譜。或許有人會說lua簡單,策划都可以學習之后寫腳本,事實上卻是寫腳本的人寫出一大堆的不敢說垃圾代碼,也算是低質量代碼,這樣更加拖累服務端的性能了。為何不學學一些比較優秀的項目,也來想辦法搞一個腳本引擎,然后寫出工具就可以讓策划配置大量的任務、戰斗這些游戲內容,復雜的邏輯直接由游戲程序員來編寫,用C++、C#多好,搞不懂為什么lua已經成為好多公司的標准了,就算不是lua也是python。就說劍網3這個游戲吧,我玩了兩年多的劍純陽,對這款游戲的體驗有足夠的了解。我們不和其它游戲的游戲比,至少在國內算優秀作品,也取得了一定的成功,雖然說抄魔獸也有點多。以前玩游戲的時候,二十多個人進個副本放些技能卡得要命,人多了在一個地圖直接卡到爆,后來一個好朋友和我說,劍網3服務端用lua寫了好多東西,能lua的多半都用lua了,一個天子峰老6,這個Boss的lua腳本竟有好幾個lua文件,每個文件幾百行代碼,我想啊,服務端完全充斥着這種低質量的腳本,還談什么效率,談什么承載人數,能跑起來就不錯了。關鍵是那個Boss的戰斗並不復雜,和魔獸很多Boss比起來就算是非常簡單的Boss了,mangos服務端一個復雜Boss的代碼都比這個簡單很多,代碼總數也僅兩百多行,執行效率更不是一個數量級的。這里發發牢騷,不用較真,言歸正傳。
Mangos服務端是一個多線程、邏輯單線程的服務端。每個線程內部都采用循環結構,主線程啟動后將創建多個工作線程,主要包括負責游戲世界運作的核心線程,具有處理用戶請求,執行定時器的能力。其它幾個工作線程還有網絡Io,該線程啟動后其內部將使用線程池進行網絡Io操作,不間斷地接收數據包,並存儲到相關玩家的消息隊列中,由世界線程進行處理,其它幾個工作線程先不討論,因為今天也是第一次看mangos的源代碼.務端啟動后這些線程將永不停息地工作。世界線程是服務器的核心,負責處理所有玩家操作請求,定時器、AI等。以下是世界線程啟動后執行的代碼:
/// Heartbeat for the World void WorldRunnable::run() { ///- Init new SQL thread for the world database WorldDatabase.ThreadStart(); // let thread do safe mySQL requests (one connection call enough) sWorld.InitResultQueue(); uint32 realCurrTime = 0; uint32 realPrevTime = WorldTimer::tick(); uint32 prevSleepTime = 0; // used for balanced full tick time length near WORLD_SLEEP_CONST ///- While we have not World::m_stopEvent, update the world while (!World::IsStopped()) { ++World::m_worldLoopCounter; realCurrTime = WorldTimer::getMSTime(); uint32 diff = WorldTimer::tick(); sWorld.Update(diff); realPrevTime = realCurrTime; // diff (D0) include time of previous sleep (d0) + tick time (t0) // we want that next d1 + t1 == WORLD_SLEEP_CONST // we can't know next t1 and then can use (t0 + d1) == WORLD_SLEEP_CONST requirement // d1 = WORLD_SLEEP_CONST - t0 = WORLD_SLEEP_CONST - (D0 - d0) = WORLD_SLEEP_CONST + d0 - D0 if (diff <= WORLD_SLEEP_CONST + prevSleepTime) { prevSleepTime = WORLD_SLEEP_CONST + prevSleepTime - diff; ACE_Based::Thread::Sleep(prevSleepTime); } else prevSleepTime = 0; #ifdef WIN32 if (m_ServiceStatus == 0) World::StopNow(SHUTDOWN_EXIT_CODE); while (m_ServiceStatus == 2) Sleep(1000); #endif } sWorld.CleanupsBeforeStop(); sWorldSocketMgr->StopNetwork(); MapManager::Instance().UnloadAll(); // unload all grids (including locked in memory) ///- End the database thread WorldDatabase.ThreadEnd(); // free mySQL thread resources }
因為是直接粘貼的,看上去比較亂,這里先作一下說明,這是世界線程的根循環結構,在while(!World::IsStopped())內部只有一個核心函數調用,其他都是一些控制更新時間之類的代碼,不用太關注:
sWorld.Update(diff);
sWorld是單一實例的World對象,它代表了整個游戲世界,和多數MMORPG一樣,啟動后進入根循環,在運行內部一直調用更新整個游戲世界的Update函數,服務端不停的Update游戲世界,每次Update能在100毫秒內完成,則客戶端會感到非常流暢。在根循環退出后,清理服務器相關資源,線程結束被回收。Mangos使用的是開源跨平台的網絡、線程處理庫ACE,這個東西粗略的看了一下,比較復雜,如果要研究透徹是很困難的事,這里提一下,不對ACE探討。到這里我們僅僅需要關注一個函數了,就是World的Update方法內部到底在干什么?
void World::Update(uint32 diff) { ///- Update the different timers for (int i = 0; i < WUPDATE_COUNT; ++i) { if (m_timers[i].GetCurrent() >= 0) m_timers[i].Update(diff); else m_timers[i].SetCurrent(0); } ///- Update the game time and check for shutdown time _UpdateGameTime(); ///-Update mass mailer tasks if any sMassMailMgr.Update(); /// Handle daily quests reset time if (m_gameTime > m_NextDailyQuestReset) ResetDailyQuests(); /// Handle weekly quests reset time if (m_gameTime > m_NextWeeklyQuestReset) ResetWeeklyQuests(); /// Handle monthly quests reset time if (m_gameTime > m_NextMonthlyQuestReset) ResetMonthlyQuests(); /// Handle monthly quests reset time if (m_gameTime > m_NextCurrencyReset) ResetCurrencyWeekCounts(); /// <ul><li> Handle auctions when the timer has passed if (m_timers[WUPDATE_AUCTIONS].Passed()) { m_timers[WUPDATE_AUCTIONS].Reset(); ///- Update mails (return old mails with item, or delete them) //(tested... works on win) if (++mail_timer > mail_timer_expires) { mail_timer = 0; sObjectMgr.ReturnOrDeleteOldMails(true); } ///- Handle expired auctions sAuctionMgr.Update(); } /// <li> Handle AHBot operations if (m_timers[WUPDATE_AHBOT].Passed()) { sAuctionBot.Update(); m_timers[WUPDATE_AHBOT].Reset(); } /// <li> Handle session updates UpdateSessions(diff); /// <li> Handle weather updates when the timer has passed if (m_timers[WUPDATE_WEATHERS].Passed()) { ///- Send an update signal to Weather objects for (WeatherMap::iterator itr = m_weathers.begin(); itr != m_weathers.end();) { ///- and remove Weather objects for zones with no player // As interval > WorldTick if (!itr->second->Update(m_timers[WUPDATE_WEATHERS].GetInterval())) { delete itr->second; m_weathers.erase(itr++); } else ++itr; } m_timers[WUPDATE_WEATHERS].SetCurrent(0); } /// <li> Update uptime table if (m_timers[WUPDATE_UPTIME].Passed()) { uint32 tmpDiff = uint32(m_gameTime - m_startTime); uint32 maxClientsNum = GetMaxActiveSessionCount(); m_timers[WUPDATE_UPTIME].Reset(); LoginDatabase.PExecute("UPDATE uptime SET uptime = %u, maxplayers = %u WHERE realmid = %u AND starttime = " UI64FMTD, tmpDiff, maxClientsNum, realmID, uint64(m_startTime)); } /// <li> Handle all other objects ///- Update objects (maps, transport, creatures,...) sMapMgr.Update(diff); sBattleGroundMgr.Update(diff); sOutdoorPvPMgr.Update(diff); ///- Delete all characters which have been deleted X days before if (m_timers[WUPDATE_DELETECHARS].Passed()) { m_timers[WUPDATE_DELETECHARS].Reset(); Player::DeleteOldCharacters(); } // execute callbacks from sql queries that were queued recently UpdateResultQueue(); ///- Erase corpses once every 20 minutes //每20分鍾清除屍體 if (m_timers[WUPDATE_CORPSES].Passed()) { m_timers[WUPDATE_CORPSES].Reset(); sObjectAccessor.RemoveOldCorpses(); } ///- Process Game events when necessary //處理游戲事件 if (m_timers[WUPDATE_EVENTS].Passed()) { m_timers[WUPDATE_EVENTS].Reset(); // to give time for Update() to be processed uint32 nextGameEvent = sGameEventMgr.Update(); m_timers[WUPDATE_EVENTS].SetInterval(nextGameEvent); m_timers[WUPDATE_EVENTS].Reset(); } /// </ul> ///- Move all creatures with "delayed move" and remove and delete all objects with "delayed remove" sMapMgr.RemoveAllObjectsInRemoveList(); // update the instance reset times sMapPersistentStateMgr.Update(); // And last, but not least handle the issued cli commands ProcessCliCommands(); // cleanup unused GridMap objects as well as VMaps sTerrainMgr.Update(diff); }
這是World::Update函數的全部代碼,服務器循環執行這些代碼,每一次執行就能更新一次游戲世界。這個函數看似比較長,實際上不算很長,其中的關鍵之處在於首先是根據定時器來執行特定的任務,而執行這些任務則是通過調用各個模塊的Manager來完成,比如游戲世界里面的屍體每20分鍾清除一次,就檢測相關的定時器是否超時,超時則清理屍體,然后重置定時器。通過這些定時器,來執行游戲中由服務器主動完成的任務,這些任務基本上是通過定時器來啟動的。游戲中的天氣系統、PvP系統、地形系統等等都根據定時器指定的頻率進行更新。除了更新各個模塊之外,其中還有個非常重要的調用:
UpdateSessions(diff);
如果翻譯過來就是更新所有會話,服務器端為每一個客戶端建立一個Session,即會話,它是客戶端與服務端溝通的通道,取數據、發數據都得通過這條通道,這樣客戶端和服務端才能溝通。在mangos的構架中,Session的作用非常重要,但其功能不僅僅取客戶端發過來的數據、將服務端數據發給客戶端那么簡單,后面會繼續結束這個Session,很關鍵的東西,下面是UpdateSessions的具體實現:
void World::UpdateSessions(uint32 diff) { ///- Add new sessions WorldSession* sess; while (addSessQueue.next(sess)) AddSession_(sess); ///- Then send an update signal to remaining ones for (SessionMap::iterator itr = m_sessions.begin(), next; itr != m_sessions.end(); itr = next) { next = itr; ++next; ///- and remove not active sessions from the list WorldSession* pSession = itr->second; WorldSessionFilter updater(pSession); if (!pSession->Update(updater)) { RemoveQueuedSession(pSession); m_sessions.erase(itr); delete pSession; } } }
其內部結構很簡單,主要遍歷所有會話,移除不活動的會話,並調用每個Session的Update函數,達到更新所有Session的目的,有1000玩家在線就會更新1000個會話,前面提到了Session,每個會話的內部都掛載有一個消息隊列,這里隊列存儲着從客戶端發過來的數據包,1000個會話就會有1000個數據包隊列,隊列是由網絡模塊收到數據包后,將其掛載到相應Sesson的接收隊列中,客戶端1發來的數據包被掛載到Session1的隊列,客戶端2的就掛載到Session2的隊列中。mangos的架構中Session不止是收發數據的入口,同樣也是處理客戶端數據的入口,即處理客戶端請求的調度中心。每次Update Session的時候,這個Update 函數的內部會取出隊列中所有的請求數據,循環地對每一個數據包調用數據包對應的處理代碼,即根據數據包的類型(操作碼OpCode)調用相應的函數進行處理,而這些“相應的函數”是Session內部的普通成員函數,以HandleXXXXXX開頭,為了便於理解,我將Session的Update函數主體核心代碼寫在這里:
bool WorldSession::Update(PacketFilter& updater) { ///- Retrieve packets from the receive queue and call the appropriate handlers /// not process packets if socket already closed WorldPacket* packet = NULL; while (m_Socket && !m_Socket->IsClosed() && _recvQueue.next(packet, updater)) { OpcodeHandler const& opHandle = opcodeTable[packet->GetOpcode()]; ExecuteOpcode(opHandle, packet); } }
這樣看起了比較清楚了,Session在Update的時候,取出所有數據包,每個數據包都有一個操作碼,opcode,魔獸模擬器有1600多個操作碼,玩家或者服務器的每個操作都有一個對應的操作碼,比如攻擊某個目標、拾取一件東西、使用某個物品都有操作碼,被追加到數據包頭部,這樣每次取數據包的操作碼,就可以查找相應的處理代碼來處理這個數據包。
從代碼里面可以看到opHandle就是根據操作碼查找到的數據處理程序,內部有相應數據處理函數的指針,ExecuteOpcode即是通過這個函數指針調用該函數來處理數據包。而處理函數實際上都是 Session的普通成員函數,當然調度處理代碼的時候並非根據操作碼進行switch判斷來調用相應處理函數,這樣會寫一個非常巨大的switch結構,mangos的方式是通過硬編碼將這些處理函數的地址存在opcodeTable這個全局的表結構中,使用OpCode作為索引,迅速地定位到相應的處理函數,即找到改數據包對應的Handler,並執行他們。
void HandleGroupInviteOpcode(WorldPacket& recvPacket); void HandleGroupInviteResponseOpcode(WorldPacket& recvPacket); void HandleGroupUninviteOpcode(WorldPacket& recvPacket); void HandleGroupUninviteGuidOpcode(WorldPacket& recvPacket); void HandleGroupSetLeaderOpcode(WorldPacket& recvPacket); void HandleGroupDisbandOpcode(WorldPacket& recvPacket); void HandleOptOutOfLootOpcode(WorldPacket& recv_data); void HandleSetAllowLowLevelRaidOpcode(WorldPacket& recv_data); void HandleLootMethodOpcode(WorldPacket& recvPacket); void HandleLootRoll(WorldPacket& recv_data); void HandleRequestPartyMemberStatsOpcode(WorldPacket& recv_data); void HandleRaidTargetUpdateOpcode(WorldPacket& recv_data); void HandleRaidReadyCheckOpcode(WorldPacket& recv_data); void HandleRaidReadyCheckFinishedOpcode(WorldPacket& recv_data); void HandleGroupRaidConvertOpcode(WorldPacket& recv_data); void HandleGroupChangeSubGroupOpcode(WorldPacket& recv_data); void HandleGroupAssistantLeaderOpcode(WorldPacket& recv_data); void HandlePartyAssignmentOpcode(WorldPacket& recv_data);
上面是極小部分的處理函數,他們都是Session的成員函數,這些函數並非是最終處理數據的,往往一個函數對應一個邏輯模塊,與這個模塊相關的操作碼有很多,比如聊天系統客戶端發來的操作碼可能是密聊、隊聊、地圖聊天,但是在Session收到數據包時,會將這個模塊的這些操作碼都調用HandleMessage函數,這些Handle函數內部會根據具體的操作碼再調用相應模塊的處理函數,就是說消息的調度是兩級的。先從入口點,通過查找OpCodeTabel找到一級調度函數、數據包傳過去后又進行二級調度,分發到更小的子模塊,直到分發的具體模塊為止。
今天暫時寫到這里,還有很多想說的,以后繼續慢慢吹,下次繼續今天沒完善的內容、談一談mangos的二進制協議、數據通信機制等內容,長期研究下mangos,肯定有好處的。