原文請參照以下文章
https://blog.photonengine.com/multithreading-in-photon/
本篇文章的要旨
在本篇文章中,我們將討論後端中的多執行緒
- 如何實行
- 如何使用
- 能夠達成哪些事情
- 我們進行哪些創新
只有在您為了伺服器端開發某些軟體時才需要思考以上問題,這些開發包含修改伺服器 SDK 程式碼、編寫您自己的外掛程式,或甚至從零開始編寫某些伺服器應用程式。
Photon 如何解決多執行緒的問題
Photon 伺服器應用程式同時從多個用戶端連結接受要求。我會將這種連結稱為同儕節點。這些要求形成了佇列。每一個同儕節點都有一個佇列。如果幾個同儕節點都連接到同一個房間,它們的佇列將合而為一——成為房間佇列。
同時可以有多達幾千個房間存在,而它們的要求佇列將受到同步處理。
在 Jetlang 程式庫的基礎上開發的Retlang 程式庫,作為 Photon 中任務佇列實行的基礎。
我們為何不使用 Task 及 async/await。
這是基於以下的考量:
- Photon 伺服器的開發,在這些功能出現之前就已經開始了
- Fiber 所執行的任務數量非常龐大——每秒數以萬計的任務。因此,沒必要再新增更多的抽象,這對我來說似乎將導致記憶體回收行程 (Garbage Collector, GC)。可以說 Fiber 抽象更加細微。
- 工作排程器 (TaskScheduler) 確實做著和 Fiber 一樣的事,我已經在評論中了解這個功能。但是一般而言我不希望因為這樣而重新設計整個功能。
Fiber 是什麼?
Fiber 是一種實行命令佇列的類別。命令將以先進先出的方式一個接著一個地被排在佇列中執行。我們可以說這裡實行的是多重寫入——單一讀取的範本。我想再一次地強調一個事實,那就是命令是根據它們被接收的順序來執行的,比方一個接著一個。這在多執行緒環境中是資料存取安全的基礎。
雖然在 Photon 之中我們只使用一種 Fiber 類型,稱為 PoolFiber,但是資料庫中提供五種類型。所有類型都實行 IFiber 界面。以下是每一種類型的簡單描述。
- ThreadFiber——由專門執行緒支持的 IFiber。這使用於頻繁地或重視效能的操作。
- PoolFiber——由 .NET 執行緒池支持的 IFiber。備註:執行上依然是依序執行,而且一次只在一個池執行緒上執行。這使用於不頻繁地、較不重視效能的執行,或在使用者不希望提高執行緒數量時使用。
- FormFiber/DispatchFiber ——由 WinForms/WPF 訊息提示支持的 IFiber。FormFiber/DispatchFiber 完全地移除了呼叫 Invoke 或 BeginInvoke 以和來自不同執行緒的視窗進行通信的需要,
- StubFiber——對於決定性測試而言相當有用。對於執行進行精密的控制以簡化測試跑分。在呼叫方執行緒上執行所有動作。
關於 PoolFiber
讓我們來談談 PoolFiber 中的任務執行。儘管它使用了執行緒池,但在 PoolFiber 中的任務仍然按照順序被執行,而且一次只使用一個執行緒。它工作的原理如下:
- 我們將一個任務放進 Fiber 佇列中,然後它就會開始執行。為了達到這個結果,將呼叫QueueUserWorkItem 。在某個時間點,會從池中選出一個執行緒,然後讓這個執行緒來執行此任務。
- 如果在第一個任務正在運行時,我們將更多的任務加入佇列中,這樣在第一個任務結束時,所有新任務將從佇列中被取出,而且 QueueUserWorkItem 將再次被呼叫,這樣所有新任務都會被送去執行。為了這些新任務,將會從池中選出新的執行緒。當完成這些新任務之後,如果佇列中還有任務,所有步驟都將從頭開始重複。
也就是說,每一次有新的任務批次,都將由池中的新執行緒所執行,但是每次只執行一個批次。因此,如果使用遊戲房間的所有任務都被放置在其 Fiber 之中,您可以安全地從這些任務中存取房間資料。如果從不同的 Fiber 中運行的任務中存取物件的話,則需要進行同步。
為了更好的說明,您可以在下一張圖片上看到這些概念。我們將 A、B、C 任務放進非常稀少的Fiber佇列中。這樣執行的情況如下:
A 任務在一個執行緒中執行(位於中間的線),B 任務在另一個執行緒中執行。對於 C 任務,系統可以選擇第三個執行緒。當任務被更頻繁地放進佇列中,有更多進行中的工作時,我們可以得到以下這張圖:
A 任務群組正在使用一個執行緒,B 任務群組正在使用第二個執行緒,C 任務群組正在使用第三個執行緒。然而我們應該理解沒有任何群組∕任務在時間線上互相交會。所有任務都嚴格地按照順序執行。
為何選擇 PoolFiber
Photon 在各處使用 PoolFiber。首先是因為它不會建立額外的執行緒,而且任何需要它的人都可以擁有自己的 Fiber。順帶一提,我們對其進行了一些修改,現在它不會被停止。比如 PoolFiber.Stop 將不會停止目前任務的執行。這對我們而言很重要。您可以在任何執行緒的 Fiber 中設定任何任務。所有設定都不影響執行緒的安全。目前任務正在執行時,也可以在執行任務中的 Fiber 中的佇列加入新的任務。
有三種在 Fiber 中設定新的任務的方法:
- 將任務放進佇列中
- 將任務放進特定間隔之後將被執行的佇列中
- 將任務放進定期被執行的佇列中。
這看起來有點像這樣:
// equeue task
fiber.Enqueue(()=>{some action code;});
// schedule a task to be executed in 10 seconds
var scheduledAction = fiber.Schedule(()=>{some action code;}, 10_000);
...
// stop the timer
scheduledAction.Dispose()
// schedule a task to be executed in 10 seconds and repeat every 5 seconds var scheduledAction = fiber.Schedule(()=>{some action code;}, 10_000, 5_000);
...
// stop the timer
scheduledAction.Dispose()
對於間隔運行的任務,維持 fiber.Schedule 所傳回的對物件的參照很重要。這是停止執行此類任務的唯一方法。
執行程式
現在來談談執行程式。這是實際執行任務的類別。它們實行了 Execute(Action a) 及 Execute(List<Action> a) 方法。PoolFiber 使用第二種方法。也就是說,任務以批次的形式進入執行程式。這些任務接下來的情況取決於執行程式。首先我們使用 DefaultExecutor 類別。它所做的是:
public void Execute(List<Action> toExecute)
{
foreach (var action in toExecute)
{
Execute(action);
}
}
public void Execute(Action toExecute)
{
if (_running)
{
toExecute();
}
}
在實際操作中,這並不足夠。因為這些「動作」之一如果出現例外狀況,所有在 toExecute 列表上的其它動作將被略過。因此預設上目前使用 FailSafeBatchExecutor,這會將 try/catch 加入到迴圈之中。如果您不需要什麼特別功能,我們推薦使用這個執行程式。我們親自加入這個執行程式,所以這並不能在 github 上的版本或其它外部的版本中使用。
我們還做出哪些創新
BeforeAfterExecutor
我們近期加入另一個執行程式以解決我們的記錄問題。這個執行程式稱為 BeforeAfterExecutor。它將傳遞給它的執行程式「包裹」起來。如果沒有任何傳遞,就會建立 FailSafeBatchExecutor。BeforeAfterExecutor 的一個特別功能是能夠在執行任務列表之前執行一個動作,並且在執行任務列表之後執行另一個動作。建構函式如下所示:
public BeforeAfterExecutor(Action beforeExecute, Action afterExecute, IExecutor executor = null)
BeforeAfterExecutor 的用途是什麼呢?Fiber 以及執行程式有相同的所有者。當建立執行程式時,將傳遞兩個動作給執行程式。第一個動作將機碼值組加入到執行緒內容中,而第二個動作則將它們移除,這樣就執行了更簡潔的功能。加入到執行緒內容的機碼值組是由記錄系統加入到訊息之中,我們可以看到一些留下訊息的物件的中繼資料。
例子:
var beforeAction = ()=>
{
log4net.ThreadContext.Properties["Meta1"] = "value";
};
var afterAction = () => ThreadContext.Properties.Clear();
//we create an executor
var e = new BeforeAfterExecutor(beforeAction, afterAction);
//we create PoolFiber
var fiber = new PoolFiber(e);
如果在 Fiber 中運行的任務記錄了某些內容,log4net 將會以 value 值加入Meta1 標籤。
ExtendedPoolFiber 及 ExtendedFailSafeExecutor
還有一件事情是 retlang 的原始版本中所沒有的,而我們後來開發出來的。在這個事情之前需要先說一件事情:PoolFiber(這是在 .NET 執行緒池上所運行的)。在此 Fiber 執行的任務中,我們需要同步執行 HTTP 要求。
我們用這種簡單的方式執行:
- 在執行要求之前,我們先建立同步事件;
- 執行要求的任務被送到另一個 Fiber,而且在完成時將同步事件轉為信號階段;
- 在那之後,我們開始等待同步事件。
就延展性而言,它不是最佳解決方案,而且它開始出現預料外的失敗。 結果顯示,我們在第二個步驟中放入另一個 Fiber 的任務,落入了開始等待同步事件的執行緒佇列中。 因此我們陷入了僵局。雖然不是每次都發生,但這足以讓人擔心。
而解決方法是在 ExtendedPoolFiber 及 ExtendedFailSafeExecutor 之中實行的。我們想出了暫停整個 Fiber 這個方法。在這樣的狀態下,它可以在新佇列中累積新的任務,但又不會執行它們、為了暫停 Fiber,需要呼叫暫停方法。當它被呼叫時,Fiber(也就是 Fiber 執行程式)將等待,直到目前的任務被完成及凍結。所有其他任務將會等待以下兩個事件中先發生者:
- 呼叫繼續方法
- 逾時(當呼叫暫停方法時被特定)。在繼續方法中,您也可以設定一個在所有佇列中的任務之前將被執行的任務。
當外掛程式需要透過 HTTP 要求來載入房間狀態時,我們將使用這個技巧。為了讓玩家立刻看見更新後的房間狀態,該房間的 Fiber 將被暫停。當呼叫繼續方法時,我們傳送一個已經應用載入狀態的任務給它,而其它所有任務都已經使用了更新後的狀態。
順帶一提,將 Fiber 轉為暫停狀態的需要完全消滅了將 _ThreadFiber 用於遊戲房間任務佇列的能力。
IFiberAction
IFiberAction 是一個減少記憶體回收行程載入的實驗。我們不能控制 .NET 中建立動作的過程。因此,我們決定以實行 IFiberAction 界面的類別的執行個體,來替換標準的動作。這是假設這些類別的執行個體是從物件池中取出,並且在完成後立刻傳回。這減少了記憶體回收行程的載入。
IFiberAction 的界面看起來是這樣:
public interface IFiberAction
{
void Execute()
void Return()
}
執行方法準確地包含了需要執行的內容。在執行方法之後,應該將物件傳回池時,將呼叫傳回方法。
例子:
public class PeerHandleRequestAction : IFiberAction
{
public static readonly ObjectPool<PeerHandleRequestAction> Pool = initialization;
public OperationRequest Request {get; set;}
public PhotonPeer Peer {get; set;}
public void Execute()
{
this.Peer.HandleRequest(this.Request);
}
public void Return()
{
this.Peer = null;
this.Request = null;
Pool.Return(this);
}
}
//now we use it next way
var action = PeerHandleRequestAction.Pool.Get();
action.Peer = peer;
action.Request = request;
peer.Fiber.Enqueue(action);
結論
最後我簡單總結一下:為了確保 Photon 中執行緒的安全,我們使用任務佇列,也就是 Fiber。我們使用的 Fiber 的主要類型是 PoolFiber 以及延展它的類別。PoolFiber 在標準 .NET 執行緒池上實行了一個任務佇列。由於 PoolFiber 較小的效能使用量,任何需要它的人都可以擁有自己的 Fiber。如果您需要暫停任務佇列,請使用 ExtendedPoolFiber。
實行 IExecutor 界面的執行程式直接地在 Fiber 中執行任務。DefaultExecutor 對每個人而言都相當有益,但當例外狀況發生的時候,它會失去所有傳遞給它執行的剩餘任務。FailSafeExecutor 在這樣的考量下似乎是一個合理的選擇。如果您需要在執行程式執行任務批次之前及之後執行某些動作,BeforeAfterExecutor 相當有用。
評論
0 條評論
請登入寫評論。