從上一篇文章的反響來看,似乎大家對於這一話題並沒有太大興趣。而這篇文章將會為大家帶來一個簡單但完整的Actor模型實現。此外,在下一篇文章中……可能會出現一些讓您覺得有趣的東西。:)
任務分配邏輯
如上文所述,這次要實現的是一個非常簡單的Actor模型,使用基於事件的分配方式,直接把任務交給.NET自帶的執行緒池去使用。不過我們又該什麼時候把一個Actor推入執行緒池的執行佇列呢?這其實取決於我們執行Actor的兩個“基本原則”:
- 如果Actor的郵箱中包含訊息,那麼要儘早執行。
- 對於單個Actor物件來說,它的訊息是順序執行的。
因此,我們有兩個“時機”可以把一個Actor交由執行緒池去執行:
- 當Actor接收到一個訊息(且該Actor處於“等待”狀態)
- 當Actor執行完一個訊息(且Actor的郵箱中存在更多訊息)
顯然,在進行操作時需要小心處理併發造成的問題,因為一個“執行完”和多個“接受到”事件可能同時出現。如果操作不當,則容易出現各種錯誤的情況:
- 某個Actor的郵箱未空,卻已停止執行。
- 同一個Actor的兩個訊息被並行地處理。
- Actor的郵箱已經沒有訊息,卻被要求再次執行。
至於並行控制的方式,就請關注下面的實現吧。
簡單的Actor模型實現
Actor模型中最關鍵的莫過於Actor物件的實現。一個Actor的功能有如下三種:
- 將訊息放入郵箱
- 接受並處理訊息
- 迴圈/退出迴圈
因此Actor抽象類對外的介面大致如下:
public abstract class Actor<T> : IActor { protected abstract void Receive(T message); protected void Exit() { ... } public void Post(T message) { ... } }
三個方法的簽名應該已經充分說明了各自的含義。不過IActor又是什麼呢?請看它的定義:
internal interface IActor { void Execute(); bool Existed { get; } int MessageCount { get; } ActorContext Context { get; } }
這是一個internal修飾的型別,這意味著它的訪問級別被限制在程式集內部。IActor介面的作用是作為一個統一的型別,交給Dispatcher——也就是Actor模型的任務分發邏輯所使用的。IActor介面的前三個成員很容易從名稱上理解其含義,那麼ActorContext又是做什麼用的呢?
internal class ActorContext { public ActorContext(IActor actor) { this.Actor = actor; } public IActor Actor { get; private set; } ... } public abstract class Actor<T> : IActor { protected Actor() { this.m_context = new ActorContext(this); } private ActorContext m_context; ActorContext IActor.Context { get { return this.m_context; } } }
在多執行緒的環境中,進行一些同步控制是非常重要的事情。執行緒同步的常用手段是lock,不過如果要減小鎖的粒度,那麼勢必會使用Interlocked類下的CAS等原子操作,而那些操作只能針對最基礎的域變數,而不能針對經過封裝的屬性或方法等成員。ActorContext便包含了用於同步控制,以及其他直接表示Actor內部狀態各種欄位的物件。這樣,我們便可以通過ActorContext物件來實現一個Lock-Free的連結串列或佇列。您可以會說,那麼為什麼要用獨立的ActorContext型別,而不直接把欄位放置在統一的基類(例如ActorBase)中呢?這有兩點原因,第一點是所謂的“統一控制”便於管理,而第二點才是更為關鍵的:後文會涉及到F#對這Actor模型的使用,只可惜F#在對待父類的internal成員時有一個bug,因此不得不把相關實現替換成介面(IActor)。不過這不是本文的主題,我們下次再討論F#的問題。
ActorContext目前只有一個欄位——沒錯,只需要一個,這個欄位便是表示狀態的m_status。
internal class ActorContext { ... public const int WAITING = 0; public const int EXECUTING = 1; public const int EXITED = 2; public int m_status; }
m_status欄位的型別為int,而不是列舉,這是為了可以使用Interlocked中的CAS操作。而對這個狀態的操作,也正好形成了我們同步操作過程中的“壁壘”。我們的每個Actor在任意時刻都處於三種狀態之一:
- 等待(Waiting):郵箱為空,或剛執行完一個訊息,正等待分配任務。
- 執行(Executing):正在執行一個訊息(確切地說,由於執行緒池的緣故,它也可能是還在佇列中等待,不過從概念上理解,我們認為它“已經”執行了)。
- 退出(Exited):已經退出,不會再執行任何訊息。
顯然,只有當m_status為WAITING時才能夠為Actor分配運算資源(執行緒)以便執行,而分配好資源(將其推入.NET執行緒池)之後,它的狀態就要變成EXECUTING。這恰好可以用一個原子操作形成我們需要的“壁壘”,可以讓多個“請求”,“有且只有一個”成功,即“把Actor的執行任務塞入執行緒池”。如下:
internal class Dispatcher { ... public void ReadyToExecute(IActor actor) { if (actor.Existed) return; int status = Interlocked.CompareExchange( ref actor.Context.m_status, ActorContext.EXECUTING, ActorContext.WAITING); if (status == ActorContext.WAITING) { ThreadPool.QueueUserWorkItem(this.Execute, actor); } } ... }
CompareExchange方法返回這次原子操作前m_status的值,如果它為WAITING,那麼這次操作(也僅有這次操作)成功地將m_status修改為EXECUTING。在這個情況下,Actor將會被放入執行緒池,將會由Execute方法來執行。從上述實現中我們可以發現,這個方法在多執行緒的情況下也能夠正常工作。那麼ReadyToExecute方法該在什麼地方被呼叫呢?應該說是在任何“可能”讓Actor開始執行的時候得到呼叫。按照文章開始的說法,其中一個情況便是“當Actor接收到一個訊息時”:
public abstract class Actor<T> : IActor { ... private Queue<T> m_messageQueue = new Queue<T>(); ... public void Post(T message) { if (this.m_exited) return; lock (this.m_messageQueue) { this.m_messageQueue.Enqueue(message); } Dispatcher.Instance.ReadyToExecute(this); } }
而另一個地方,自然是訊息“執行完畢”,且Actor的郵箱中還擁有訊息的時候,則再次為其分配運算資源。這便是Dispatcher.Execute方法的邏輯:
public abstract class Actor<T> : IActor { ... bool IActor.Existed { get { return this.m_exited; } } int IActor.MessageCount { get { return this.m_messageQueue.Count; } } void IActor.Execute() { T message; lock (this.m_messageQueue) { message = this.m_messageQueue.Dequeue(); } this.Receive(message); } private bool m_exited = false; protected void Exit() { this.m_exited = true; } ... } internal class Dispatcher { ... private void Execute(object o) { IActor actor = (IActor)o; actor.Execute();
當程式執行到此處時,actor的Execute方法已經從郵箱尾部獲取了一條訊息,並交由使用者實現的Receive方法執行。同時,Actor的Exit方法也可能被呼叫,使它的Exited屬性返回true。不過到目前為止,因為ActorContext.m_status一直保持為EXECUTING,因此這段時間中任意新訊息所造成的ReadyToExecute方法的呼叫都不會為Actor再次分配運算資源。不過接下來,我們將會修改m_status,這可能會造成競爭。那麼我們又該怎麼處理呢?
如果使用者呼叫了Actor.Exit方法,那麼它的Exited屬性則會返回true,我們可以將m_status設為EXITED,這樣Actor再也不會回到WAITING狀態,也就避免了無謂的資源分配:
if (actor.Existed) { Thread.VolatileWrite( ref actor.Context.m_status, ActorContext.EXITED); } else {
如果Actor沒有退出,那麼它會被短暫地切換為WAITING狀態。此後如果Actor的郵箱中存在剩餘的訊息,那麼我們會再次呼叫ReadyToExecute方法“嘗試”再次為Actor分配運算資源:
Thread.VolatileWrite( ref actor.Context.m_status, ActorContext.WAITING); if (actor.MessageCount > 0) { this.ReadyToExecute(actor); } } } }
顯然,在VolatileWrite和ReadyToExecute方法之間,可能會到來一條新的訊息,因而再次引發一次並行地ReadyToExecute呼叫。不過根據我們之前的分析,這樣的競爭並不會造成問題,因此在這方面我們可以完全放心。
至此,我們已經完整地實現了一個簡單的Actor模型,邏輯清晰,功能完整——而這一切,僅僅用了不到150行程式碼。不用懷疑,這的確是事實。
使用示例
Actor模型的關鍵在於訊息傳遞形式(Message Passing Style)的工作方式,通訊的唯一手段便是傳遞訊息。在使用我們的Actor模型之前,我們需要繼承Actor<T>類來構建一個真正的Actor型別。例如一個最簡單的計數器:
public class Counter : Actor<int> { private int m_value; public Counter() : this(0) { } public Counter(int initial) { this.m_value = initial; } protected override void Receive(int message) { this.m_value += message; if (message == -1) { Console.WriteLine(this.m_value); this.Exit(); } } }
當計數器收到-1以外的數值時,便會累加到它的計數器上,否則便會列印出當前的值並退出。這裡無需做任何同步方面的考慮,因為對於單個Actor來說,所有的訊息都是依次處理,不會出現併發的情況。Counter的使用自然非常簡單:
static void Main(string[] args) { Counter counter = new Counter(); for (int i = 0; i < 10000; i++) { counter.Post(i); } counter.Post(-1); Console.ReadLine(); }
不過您可能會問,這樣的呼叫又有什麼作用,又能實現什麼呢?您現在可以去網上搜尋一些Actor模型解決問題的示例,或者您可以等待下一篇文章中,我們使用F#來操作這個Actor模型。您會發現,配合F#的一些特性,這個Actor模型會變得更加實用,更為有趣。
此外,在下一篇文章裡我們也會對這個Actor模型進行簡單的效能分析。如果您要把它用在生產環境中,那麼可能還需要對它再進行一些細微地調整。