山姆鍋在過去的文章中,數次提到「參與者模式 (Actor Model)」這個名詞,也使用 比喻的方式 解釋它的概念。在多種分散式編程模式中,對山姆鍋來說,這是最不會讓頭腦打結的方法。

本文山姆鍋試圖整理寫過的一個參與者框架,雖然不完整,希望還是多少對參與者模式如何運作的了解有所幫助。

設計目標 (Design Goals)

  • 參與者無須知道溝通的對象所在的主機。
  • 參與者無須知道訊息如何傳遞。
  • 訊息結構可以擴展,容許應用程式彈性利用。
  • 支援 「訊息通知 (notification)」的通訊模式。
  • 支援 「請求 - 回應 (request-response)」通訊模式。
  • 訊息傳輸協定 (transport protocol) 可以更換。

物件模型 (Object Model)

框架定義了一些應用程式該繼承或使用的類別,底下說明框架中主要的類別。

Simple actor framework

Actor

代表框架中的「參與者」物件。每個參與者都有一個全域 (global) 唯一的名稱 (name),在同一個引擎 (engine), 這個名稱必須是唯一。

每個參與者會有一個信箱 (mailbox),存放該參與者收到但尚未處理的訊息。在此框架,參與者不會明確呼叫來從信箱取得訊息,而是由框架推送 (push) 給參與者物件。

ActorRef

代表參與者的「參考」物件。這個物件封裝與一個參與者溝通的必要資訊,如:參與者名稱、所在引擎的網路位址等。 同時提供幾個關鍵方法:

  • tell: 用來傳送通知 / 一般訊息給此參與者。
  • ask: 用來送出請求訊息給此參與者。
  • get_proxy: 用來取得一個特別的代理 (proxy) 物件,此代理物件可以把方法呼叫轉化成請求訊息。

ActorProxy

參與者的代理物件,用來把傳送請求訊息轉化成對此代理物件的呼叫。運用到 Python 的 __getattribute__ 方法, 攔截方法呼叫並轉成訊息送給對應的參與者。 ActorRefget_proxy 方法會回傳一個 ActorProxy 物件。

ActorContext

針對每個參與者物件,框架會配置一個 ActorContext 物件。此物件擔任參與者與框架之間的主要介面, 同時封裝關於此參與者的重要資訊。底下是此類別主要方法:

  1. `get_name(self)`:

    取的此參與者的名稱。

  2. `get_self_ref(self)`:

    取得此參與者的「參考」物件。 當需要以非同步的方式接受通知時,需要像事件源註冊,此時便會需要取得自己 的參考物件。因為所有溝通都只能透過參考物件,直接回傳參與者物件並沒有用處。

  3. `get_sender_ref(self)`:

    取的目前傳送目前訊息的參與者參考物件。這個方法只在處理訊息時可以用,底層使用 Greenlet local 物件 來記錄與傳遞當前的訊息傳送者,是一種類似 Thread Local Storage 的機制。

  4. `spawn(self, actorcls, _args,*kwargs)`:

    派生出新的參與者。

  5. `make_ref(self, actor_name, address)`:

    根據參與者名稱與網路位址建構一個 ActorRef(參考) 物件。

  6. `serialize_ref(self, actor_ref)`:

    把一個 ActorRef 物件序列化成適合網路傳遞或儲存的格式。

  7. `deserialize_ref(self, ref_str)`:

    把經過序列化的 ActorRef 物件反序列化成物件。

Message

總共有四個具體 (concrete) 的訊息類別,分別為:

  1. Message:

    代表一般或者通知訊息,用在不需要接收者回應的情境。

  2. Request:

    代表請求的訊息,預期接受者回傳成功或者失敗的回應。

  3. Result:

    代表請求執行成功的回應訊息,通常也會內涵執行結果。

  4. Error:

    代表請求執行錯誤的回應訊息,包含基本錯誤訊息。

訊息相關的類別定義在: https://github.com/sampot/simpleactors/blob/master/ava/actor/message.py

ActorEngine

此類別定義實作參與者框架的主要邏輯,如派生參與者物件、遞送訊息與處理訊息逾時等等工作。 每個引擎物件會有一個網路位址用來跟其它引擎通訊。如果參與通訊的參與者都屬於同一個引擎, 則訊息會採取捷徑方式處理並不會到網路層。

通訊模式

參與者之間只能透過訊息傳遞來互相溝通,而且參與者只能跟它已經有「參考」物件的對象通訊。 參與者之間可以分成兩種溝通模式:

  • 通知模式 (notification):

    這種模式很容易理解,就是訊息送出去後就不管了,也可以說是 fire-and-forget 模式。 在框架中, tell 操作就是用來實現這種通訊模式。

  • 請求 - 回應模式 (request-response):

    這種模式下,送出訊息的參與者會預期接收的參與者會給予回應,不管是表示成功或失敗結果。 在框架中,底下三種訊息類別分別代表請求、成功的結果、失敗的例外訊息:

    1. Request:
      封裝一個代表請求的訊息,請求訊息包含要接收者執行的動作 (action) 以及參數。
    2. Result:
      封裝代表請求的執行結果。
    3. Error:
      封裝網路或者請求錯誤的資訊。逾時 (timeout) 沒有收到回應,框架也會以一個 Error 訊息通知參與者。

    參與者藉由呼叫 ask 方法來送出請求,並根據回應來判斷是成功、失敗或者逾時。

參與者實作概觀

參與者需要以一個類別實現,這個類別不一定需要繼承自框架提供的 Actor 類別,但參與者類別至少需要符合下列要求:

  1. 如果有建構子,除了 `actor_context` 這個具名參數 (named parameter) 外,其餘參數都必須有預設值。

有兩個特別的方法,用來處理訊息跟要求:

  • `handle_message`: 只有在此參與者收到一般訊息 (通知訊息) 時,框架才會呼叫此方法。
  • `handle_action`: 當參與者收到請求訊息,但沒有對應的處理方法時,框架會呼叫這個方法。

參與者類別如果有定義 on_ 開頭的方法,如果收到對應動作 (action) 的請求訊息,框架會自動呼叫該方法, 這時就不會再呼叫 handle_action。 例如:參與者收到一個叫做 ‘hello’ 的動作要求, 則框架會呼叫 on_hello 這個方法。

具體的範例請參考使用情境中的 PongActor 類別。

使用情境 (Use Scenarios)

底下使用情境使用一個叫做 `PongActor` 的參與者實作,程式碼如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
class PongActor(object):
instance = None

def __init__(self, actor_context):
self.context = actor_context
print("PongActor created.")
self.called = False
self.last_msg = None
PongActor.instance = self

def on_ping(self, s):
self.called = True
self.last_msg = s
return s

def on_error(self):
self.called = True
raise RuntimeError('Pong error')

def on_get_sender(self):
self.called = True
return self.context.get_sender_ref().actor_name

def handle_message(self, msg):
self.called = True
self.last_msg = msg

def handle_action(self, *args, **kwargs):
self.called = True
self.last_msg = kwargs
return kwargs

這個參與者也實際被用在框架的測試案例中,那些額外的屬性都是為了測試需要。

取得 ActorContext 物件

參與者只有在被建構的當下才有機會取的它對應的 ActorContext 物件。參與者類別必須定義一個接受名為 actor_context 的具名參數,框架在產生此參與者物件時會帶入 ActorContext 物件。

例如下面的 __init__ 宣告:

1
2
def __init__(self, actor_context):
self._context = actor_context

這個方法可以有其它參數,只要它們都有預設值。

為了判斷方法是否接受一個名為 actor_context 的參數,使用了 Python 內建的 inspect 套件。 該技巧應該有參考價值,特別收錄下列建立參與者物件的片段:

1
2
3
4
5
6
argspec = inspect.getargspec(actor_cls.__init__)
if 'actor_context' in argspec.args:
# 判斷是否有'actor_context' 這個參數
# 有的話,插入 ActorContext 物件的具名參數。
kwargs['actor_context'] = context
_actor = actor_cls(*args, **kwargs)

其中,`actor_cls` 是參與者類別。

ActorContext 是參與者物件與框架互動的介面,除非是不需要其它參與者通訊的參與者, 不然大概都會採用這個方式取的 ActorContext 物件。

派生一個參與者 (Spawn Actors)

假設 actor_engine 是參與者引擎物件,則可以使用下面片段來派生新的參與者:

1
pong_ref = actor_engine.spawn(PongActor)

其中,`PongActor` 是參與者的實作類別,`spawn` 方法會傳回該參與者物件的「參考」。 透過這個「參考」物件,其它參與者便可以跟它溝通。

上述的方法是針對框架本身要派生新的參與者物件時使用,參與者物件則透過關聯的 ActorContext 物件:

1
pong_ref = actor_context.spawn(PongActor)

傳送通知訊息給參與者

假設 pong_ref 是一個參與者的「參考」物件,下面片段示範如何送一個通知訊息給它:

1
2
3
4
5
msg = message.Message()
msg.receiver = pong_ref.actor_name
msg.msg_id = b'msgid1234'

pong_ref.tell(msg)

過程中沒有錯誤的話,`PongActor` 的 handle_message 方法會被呼叫來處理此訊息。

要求參與者處理一個動作

1
2
3
4
5
6
7
req = message.Request()
req.receiver = pong_ref.actor_name
req.action = 'ping' # 1
req.arguments = {'s': 'hello'}

result = pong_ref.ask(req)
text = result.get(1)

上述程式片段會呼叫到 PongActoron_ping 這個方法。

使用代理物件簡化請求 - 回應

同樣假設 pong_refPongActor 的一個參考物件,下面程式片段示範如何使用參與者代理物件來簡化請求:

1
2
pong = pong_ref.get_proxy()
text = pong.ping(s='world')

PongActoron_ping 方法同樣會被呼叫,只是在程式碼這個層級看起來比較容易理解, 但背後還是採用標準的訊息傳遞機制在運作。

實作構想

  1. 一開始設計時,實作就決定採用 Gevent 跟 Greenlet。每個 Actor 都由一個 greenlet 來負責。
  2. 訊息的實際傳送由原始碼中的 net 套件所負責,它本身也自成一個通訊框架,由於主要目的是說明參與者框架,就不在此詳述。

已知限制

  1. 框架不維護參與者之間的階層關係 (hierarchical relationships),所以當參與者意外結束時,並不會通知上層負責監管的參與者。
  2. 訊息傳遞是基於 UDP 協定,跨主機通訊時,訊息有可能會遺失、順序顛倒,這些都是參與者實作要處理。

結語

山姆鍋越寫越覺得這個框架還真的蠻陽春的,不過還好山姆鍋夠阿 Q,會正面思考,總覺得會有些值得參考的地方吧!

參考資料

`simepleactors`: https://github.com/sampot/simpleactors

本文所討論的參與者框架原始碼。