寫在前面的
存在即合理, 不管什么事, 都是有原因有理由有前提的, 所以在談?wù)撝拔覀兿纫鞔_一些東西
1. 服務(wù)器端使用多線程的必要條件是多核, 且物理核的計算能力總和>>服務(wù)器程序的計算量. 如果不滿足上述條件, 應(yīng)該先考慮硬件配置問題.
2. 為什么要用多線程? 是為了充分利用多核增加計算量, 增大計算量的目的是什么? 支持更為復(fù)雜的玩法. 這里有很重要的信息, 那就是"支持更為復(fù)雜的玩法"是目的, 而"增加計算量"是達到目的的手段; 前者是策劃域問題, 而后者是程序域問題. 一個需求和實現(xiàn)的結(jié)合點.
3. 當(dāng)計算量達到時, 其他短板開始起作用, 也就是說不要去追求所謂最好利用計算量之方法, 轉(zhuǎn)而追求計算量不要成為最短板即可.
這片文章是在有"支持更為復(fù)雜的玩法"這樣的需求, 且當(dāng)前存在"計算量目前是最短板", 且"由于沒有充分利用多線程發(fā)揮機器多核計算量即計算量有較多剩余"的情況下的一些探討和心得.
申明: 本文并不想得出任何"我這個就對!"的方案, 只是將我在實踐中遇到的問題, 和解決問題或是看到的一些思路寫出來, 期望能引發(fā)大家的探討:) 相比之下文中提高的方案只不過是一個example罷了
一個地圖一個線程的方案
關(guān)于在游戲服務(wù)器中使用多線程, 有一個常常被提到的方案是: 一個地圖一個線程, 這個方案的一個推廣就是平行子系統(tǒng)中每個子系統(tǒng)對應(yīng)一個線程. 個人認(rèn)為這個方案還是值得商榷的. 下面來說明一下我的看法.
根據(jù)一些講解系統(tǒng)的名著中的定義: 在問題域的世界中, 交互聯(lián)系格外緊密的一群實體之間形成了一個系統(tǒng). 一個子系統(tǒng)之所以被劃分成為一個子系統(tǒng), 是因為在子系統(tǒng)中的成員的交互和聯(lián)系頻率要遠遠大于和系統(tǒng)外實體的交互和聯(lián)系, 這是一個很重要的結(jié)論. 我們可以從這里得到一個推論: 劃分良好的子系統(tǒng)內(nèi)部交互非常緊密, 和外部實體交互相對較弱. 很巧的是我們在設(shè)計中往往會把這里的子系統(tǒng)劃分為程序中的一個功能模塊, 模塊也有這樣的性質(zhì), 模塊中的交互要遠遠大于模塊間的交互.
有了上述理論的支持, 再來看看為什么說"一個地圖一個線程"這樣的方案不可取? 一個地圖是一個子系統(tǒng), 地圖間的交互是非常小的, 的確是這樣, 可能就是在不同地圖中移動時等不頻繁事件. 這里可以分析一下"地圖綁定線程"這個想法的依據(jù):
1. 增大計算量. 在系統(tǒng)中引入多線程, 利用多核, 加大服務(wù)器端的計算能力(提供更多的游戲邏輯處理), 這樣就可以在一個服務(wù)器上容納更多的玩家.
2. 防止犯錯. 避開多線程的鎖的問題, 把交互性強的部分分在同一個線程中, 這樣的話在大部分情況下避免鎖, 只有在很少的跨地圖交互時才使用同步方法來保證[避免鎖可以防止鎖的競爭, 更重要的是在實際編程中解放程序員, 因為時刻考慮多線程問題太痛苦了, 犯錯不可避免]. 放在這里就可以發(fā)現(xiàn)按地圖劃分是一個很好的劃分線程的方法.
該方法的問題在于首先它不一定很發(fā)揮出多核的效果, 因為游戲中人不太會是均勻的分布在多個地圖上. 這樣就會產(chǎn)生有的線程都忙不過來了[地圖人很多], 有的線程很閑[地圖人很少], 問題的本質(zhì)是這種使用線程的方法限制了每個地圖的可使用計算量[無形中的一個假設(shè)是: 單個核的計算量是足夠的, 一旦現(xiàn)實情況不符, 服務(wù)器端就會掉幀], 也就是說對每個地圖來說, 還是單線程的, 并沒有達到想要的"增大計算量"的目地. 其次, 系統(tǒng)的穩(wěn)定性差, 因為只要有一個線程core掉, 后果可想而知. 再次, 暫時不談前兩個問題, 那么在計算量得到充分滿足的條件下, 根據(jù)"短板效應(yīng)", 你的系統(tǒng)的能力又會開始受到內(nèi)存, 網(wǎng)絡(luò)等其他因素的影響, 這時你可能會想要在其他機器上橫向擴展系統(tǒng), 你必須單獨設(shè)計這一部分, 多線程體系本來就有"我能發(fā)揮機器的計算能力, 計算量從此不是問題"這樣的傾向, 但是它的一個不可回避的問題就是, 多線程的執(zhí)行流共享程序進程空間, 它必須在同一臺主機上. 分布式的橫向擴展對多線程來說是另一個課題, 它從來沒想過這個, 你得自己去實現(xiàn).
其實根據(jù)設(shè)計的特點, 這里還不如只用多進程來代替多線程, 因為:
1. 多進程同樣發(fā)揮多核的計算能力, 至少在"地圖綁定線程"這個問題上不弱于多線程, 但也不會強, 因為也受到單線程地圖的影響.
2. 多進程穩(wěn)定性好, 一個地圖進程core了, 其他地圖不受影響.
3. 使用socket進行地圖[進程]間通信手段, 天然支持分布式部署, 在系統(tǒng)需要橫向擴展時, 不用做任何的額外工作.
總的來說, 這種平行子系統(tǒng)中一個子系統(tǒng)對應(yīng)一個線程的方法看似使用的多線程, 但是本質(zhì)上還是單線程的設(shè)計思路, 所以不是一種很好的方案, 但是這個方法有它的可行性和合理性, 因為它的確是分?jǐn)偭擞嬎懔? 而且不用過度考慮鎖的問題, 所以它還是有很重要的參考價值.
去現(xiàn)實世界中尋找答案
另一個想法是, 游戲世界中的每一個active obj都有一個線程+一個消息對列. 這個想法看起來不現(xiàn)實, 但是它是出于對現(xiàn)實世界中可響應(yīng)的實體的最好模擬. 我記得剛工作的時候, 帶我的人最后也是my friend的--徐曉剛同學(xué)就告訴我, 游戲中的實體一定要和現(xiàn)實生活中的類似實體有對應(yīng), 現(xiàn)實實體是怎么樣的, 你游戲中的實體就做成什么樣, 當(dāng)時我聽了覺得不對啊, 不是要抽象么什么什么的, 典型的書看多了, 但是現(xiàn)在看來確實是這樣的, 游戲中的實體都是源于生活的, 要想做一個好的模擬, 還是得觀察和研究現(xiàn)實中的實體才行.
比如現(xiàn)實生活中確實就是這樣, 可以有反應(yīng)的實體都是時刻在接受著外界的信息(消息), 然后它的反應(yīng)其實就是在處理這個信息[在它自己的線程里處理這個消息]. 比如說, 你丟一個骨頭給一個小狗, 就相當(dāng)于給它發(fā)了個消息[你有吃的了], 小狗就會處理這個消息[啃骨頭]. 再比如說, 媽媽告訴小孩, 去把垃圾倒了, 小孩聽到這個消息, 就會去倒垃圾. 可以看出來, 這都是 可執(zhí)行體+消息 的結(jié)構(gòu). 當(dāng)然, 媽媽也可以告訴小孩, 你去把垃圾倒了, 然后再去商店買一點鹽. 小孩收到這兩個消息的時候, 就會記下來, 先去到垃圾, 然后去買鹽, 這個其實相當(dāng)于小孩有一個記錄消息的隊列, 他會依次處理隊列中的消息.
所以這里我個人得出的結(jié)論是在多線程系統(tǒng)中:
1. 系統(tǒng)本身是內(nèi)聚封閉的, 它不能直接去查看不是自己內(nèi)部的數(shù)據(jù), 方法等, 別人也不能直接來查看它的相關(guān)信息, 系統(tǒng)只負責(zé)維護自己的狀態(tài).
2. 如果需要交互, 系統(tǒng)間應(yīng)該顯式的走消息機制.
在現(xiàn)實生活中找到有反應(yīng)的實體并觀察它們的行為后, 就可以尋找它們的共同點, 這個過程就是抽象, 抽象并不是打開編輯器, 想著自己要寫代碼來抽象這個問題呀, 而是在現(xiàn)實中找到對應(yīng)并歸納共性的過程. 這里看到, 可以獨立的反應(yīng)的實體具有兩個性質(zhì):
1. 可以獨立行為---也就是說他們是active的. 一個npc是一個系統(tǒng), 一個player也是一個系統(tǒng).
2. 有一個消息隊列---他們可以處理隊列中的消息.
映射到程序中, 可以獨立行為就單獨給它分一根線程唄, 消息隊列不是問題, 每個線程的唯一任務(wù)就是處理消息隊列中的消息. 但是如果能這么簡單就不叫"源于生活高于生活了". 這樣的弊端一看就看出來了, 游戲中每個有反應(yīng)的實體一個線程不現(xiàn)實, 就拿我知道的來說, 5000個npc和2000個玩家吧, 都是有反應(yīng)的實體, 一共啟動5000+2000=7000根線程, 顯然不現(xiàn)實, 開銷上受不了.
但是這個抽象的確很不錯, 很符合現(xiàn)實, 所以我不想放棄它. 那么還是要從現(xiàn)實世界中學(xué)習(xí)如何"降低開銷", 類比一下, 比如一個食堂的老板, 他雇傭了一個洗白菜工, 一個洗芹菜工, 一個切白菜工, 一個切芹菜工, 一個炒白菜工, 一個炒芹菜工, 這樣的配置不能說錯, 沒問題可以工作, 不過過了幾天老板就發(fā)現(xiàn)了, 開銷太大了, 每個月掙的不如花的多, 而且還有人員有大把空閑時間. 于是他讓洗菜的不管白菜芹菜一起洗, 切菜和炒菜的人也一樣. 這樣就剩下3個人了, 過了幾天后老板又開掉了洗菜的人, 讓切菜的人兼上洗菜的活, 這樣就只剩下兩個人的人力開銷.
類比到計算機中, 我們創(chuàng)建的線程就是雇傭工人, 工人不是免費的, 是有人力成本的, 線程也一樣, 它不是免費的, 是有開銷的. 所以你創(chuàng)建不了太多的線程, 就和你請不起太多的員工是一個道理. 在現(xiàn)實生活中通過讓一個員工干更多的活, 來達到降低成本的方法, 在這里也是適用的.
首先講一個不易區(qū)分的地方, 就是"能動"和"動"的區(qū)別. 英文active和actor, 一個是形容詞, 一個是動詞. 這個很重要, 因為我們的系統(tǒng)中, active object很多, 但是actor是需要控制的, 不可能太多. actor其實直接對應(yīng)于一個線程, 而active object對應(yīng)于諸多的可反應(yīng)的實體. 比如npc, player等等.我們需要用actor來驅(qū)動active object, 沒有被驅(qū)動的active object是靜止的, 只有用actor才能賦予它活力. 所以我們可以寫下如下的代碼.
可反應(yīng)的實體的定義
class activeobj {
msgqueue mq;
void Active() {
processMsg(mq.PopFront());
}
void RecvWork(msg) {
mq.push(msg);
}
}
創(chuàng)建多個實體
activeobj objs[34];
actor驅(qū)動實體
void actor() {
while (true) {
foreach(obj in objs) {
obj.Active()
}
}
}
這里, 每個activeobj都有自己的消息隊列, 你可以使用RecvWork向activeobj發(fā)送消息, 而這個activeobj被actor激活的時候就會去處理自己消息隊列中的消息, 這樣整個世界中的obj都具有活力了, 下面代碼表示在多線程世界中上述代碼的形式, 也就是說, 存在多個actor
可反應(yīng)的實體的定義
class activeobj {
msgqueue mq;
lock mc;
void Active() {
mc.Lock();
msg m = mq.PopFront();
mc.Unlock();
processMsg(m);
}
void RecvWork(msg) {
mc.Lock();
mq.push(msg);
mc.Unlock();
}
}
創(chuàng)建多個實體
activeobj objs[100];
actor驅(qū)動實體
void actor(int i) {
while (true) {
for (j = i * 25; j < (i + 1) * 25; ++j) {
objs[j].Active()
}
}
}
創(chuàng)建actor
createActor(actor, 0)
createActor(actor, 1)
createActor(actor, 2)
createActor(actor, 3)
上面演示的策略是創(chuàng)建4個actor, 每個actor負責(zé)驅(qū)動固定的25個obj, 每個activeobj有自己的消息隊列鎖, 這樣就可以保證消息隊列的線程安全. 這樣的好處是比較簡單, 但是這個做法的問題也有很多:
1. 消息的時序不對, 比如, 我先給obj[3]發(fā)了一個消息, 然后給obj[1]發(fā)了一個消息, 那么根據(jù)上面的邏輯, obj[1]的消息很可能會先于obj[3]被處理. 如果這兩個消息之間先后沒有關(guān)系還好, 但是一旦有邏輯上的先后關(guān)系, 這樣處理就是錯誤的了. 問題的根源是: 處理消息總是按照obj的排列順序, 而不是按照收到消息的前后順序.
2. 這種方法還是落入了不能平分負載的問題中, 因為可能actor1管理的obj群不活躍, 而actor2管理的obj群異常的活躍. 就會出現(xiàn)一個線程特別忙, 另一個線程閑置的狀態(tài), 在往下說, 就是有可能發(fā)生一個CPU 100%了, 另一個CPU20%, 但是游戲掉幀, 就是因為沒有均攤計算量. 這也可能成為一個優(yōu)點, 因為不會出現(xiàn)兩個線程中同時處理一個obj的情況, 也就是說對obj自身來說是在單線程環(huán)境中的. 具有單線程的安全性.
改進方案
所以我們必須繼續(xù)思考這個問題, 上面的第二點好解決, 可以這樣
創(chuàng)建多個實體 …
lock oc;
activeobj GetObj() {
oc.Lock();
foreach(obj in objs) {
if (!obj.mq.empty()) {
objs.Remove(obj);
oc.Unlock();
return obj;
}
}
oc.Unlock();
return nullobj;
}
void actor() {
while (true) {
obj = GetObject();
if (obj != nullobj) {
obj.Active();
} else {
yield();
}
}
}
經(jīng)過上述改動以后, 可以看到, 只要有一個線程空閑下來, 它就會去查詢是否有obj存在msg等待被處理. 如果沒有, 線程就短暫的休息一下. 這樣的話, 計算量基本上可以實現(xiàn)均攤到多個線程上. 但是這種方案有明顯的一個鎖競爭的hot point, 就是GetObj函數(shù)中. 這里不適合用重量級的鎖, 而且這里的競爭會隨actor的數(shù)量上升而變得更激烈.
上面的方案算是一個解決計算分?jǐn)偟霓k法, 但是沒有解決消息的時序問題. 話說基于消息的系統(tǒng)成堆成堆的, 比如說日常中用到的windows系統(tǒng)就是基于消息驅(qū)動的, 可以從它身上學(xué)到一些東西. Windows的控件可以看做是active object, 因為它可以對你的操作進行反應(yīng), 比如你點擊它, 拖動它等等, 這些都是發(fā)消息來實現(xiàn)的. 這里就是關(guān)鍵, 消息到底發(fā)給誰了
1. 從表象上來看, 因為是控件對消息產(chǎn)生了反應(yīng), 我們從感覺上就認(rèn)為消息是發(fā)給控件的, 控件上可能有一個消息隊列, 同時我們知道, 其實控件只是active obj, 它是由actor ui線程來驅(qū)動的.
2. 但是我們從資料上可以輕松找到其實控件都沒有消息隊列, 消息隊列是和ui線程綁定在一起的:) 也就是說一個actor一個隊列, 而不是說一個active obj一個隊列.
我認(rèn)為這windows這個消息模型是很有借鑒價值的, 應(yīng)用到我們的設(shè)計中就是
可反應(yīng)的實體的定義
class activeobj {
void Active(msg m) {
processMsg(m);
}
}
actor的定義
class actor {
msgqueue mq;
void Loop() {
msg m = mq.popfront();
obj = FindObj(m.t) else goto yield();
obj.Active(m);
}
}
上述機制看上去又再一次的落入任務(wù)和線程綁定的模式中, 我們還是要再次研究, 一個游戲世界中, 只有一個時間軸, 如果說每個消息都有一個時間戳的話, 那么它們應(yīng)該是"先來后到"的關(guān)系, 因為我們要保證消息有序. 所以, 我們不能使用一個actor一個隊列的方式, 消息隊列必須是全局的, 然后又幾個actor監(jiān)視這個隊列, 一旦有消息進入的話, 就有一個actor會取到它并去處理. 為了驗證我們的想法, 我在現(xiàn)實中找到了這樣實現(xiàn)的模型, 就是目前windows下最高效的IO模型完成端口, 完成端口的完成隊列就相當(dāng)于這里的消息隊列, 而actor就是線程池, 每個actor都企圖從完成隊列中取一個任務(wù)來執(zhí)行. 這和我們的想法是非常類似的. 所以再次修改代碼:
可反應(yīng)的實體的定義
class activeobj {
void Active(msg m) {
processMsg(m);
}
}
定義一個全局的消息隊列
msgqueue mq;
mqlocal ml;
actor的定義
class actor {
void Loop() {
ml.lock();
msg m = mq.poptop() else goto yield();
ml.unlock();
obj = FIndObj(m);
obj->Active(m);
}
}
上面就是一個可選的方案, 首先它分?jǐn)偭擞嬎懔? 計算量在msgqueue中存著, 真正的處理邏輯是在activeobj中, 而actor驅(qū)動activeobj來執(zhí)行msgqueue中的消息的處理. 從設(shè)計的角度來說, 做到了職責(zé)分離. 而前面的設(shè)計中, msgqueue不是和activeobj綁定在一起, 就是和actor綁定在一起, 所以總是有一些問題, 看來還是大牛們說的對, 每個概念都單獨表示, 然后通過組合把它們聯(lián)系起來.
上面的這個方案好么? 首先, 我們的游戲中, 時序是非常重要的, 我們總是期望先到的消息能夠先處理[因為它先產(chǎn)生], 所以必須有一個消息排隊的地方, 所以有一個全局隊列是很重要的.其次就是, 我們不期望發(fā)生一個線程很忙, 另一個很閑的情況, 使用全局msg隊列就可以達到收集任務(wù), 管理任務(wù), 分配任務(wù)的效果, 上面這種模式就可以在大部分情況下平均分?jǐn)傆嬎懔?
總結(jié)
上面簡單介紹了一個多線程方案, 我并不是直接的給出了答案, 而是記錄了我在思考這個問題的過程中遇到的各種問題, 和如何解決這些問題的思路(只能說是思路:), 我認(rèn)為其中最重要的部分就是:
1. 抽象應(yīng)該是源于生活的, 不是空想的.
2. 對系統(tǒng)的看法: 一個系統(tǒng)之所以被劃分成為一個系統(tǒng), 是因為在系統(tǒng)中的成員的交互和聯(lián)系頻率要遠遠大于和系統(tǒng)外實體的交互和聯(lián)系.
3. 各個系統(tǒng)不直接交互, 如果必須交互, 請一定走MSG
4. MSG應(yīng)該維持時序性, 我這里使用的是全局MSG隊列來保證時序性.
在文中討論到最后的方案中, 任然存在著一些問題, 比如說在這種方案下, 全局的MSG隊列就是唯一一個全局且高競爭數(shù)據(jù), 這里鎖的互斥是高發(fā)的, 所以在選擇鎖的時候需要斟酌等等, 希望能有所幫助, 歡迎大家提出更好的解決方案, 建設(shè)性的交流總是有益的:)