Mangos魔兽世界服务端初探(1)--游戏服务端主体结构与消息分发
魔兽时间是暴雪著名的网络游戏,我以前也玩过一段时间的战士,这款游戏目前已进入晚年时期,不过里面各种丰富的游戏系统和游戏内容都非常让人印象深刻。开源的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,肯定有好处的。