用 Active Object Pattern 處理 multithreading

fcamel
fcamel的程式開發心得
8 min readFeb 17, 2019

之前的文章條列一些 multithreading 的想法,這篇比較有系統的說明為何會導出 Active Object Pattern。

寫 multithreading 的時候,為了跨 thread 存取同一份資料,一開始會想到用 mutex (也稱作 lock)。用 mutex 保護的資料同時間只有一個 thread 存取,看起來很直覺,事實上有許多設計的眉角,其實不易作對。

問題一: 要保護那些資料?

若程式內沒用註解或命名規則說清楚 mutex 保護的資料,讀碼時很痛苦,修改的人也很怕改錯。詳細的註解建議,可參考 Chrome C++ Lock and ConditionVariable 的 “Recommended commenting convention”。

為了效率,要盡可能用不同 mutex 保護不同組資料。相反的,為了易於維護,會用一個 mutex 保護所有資料。CPython 的 GIL (Global Interpreter Lock) 是極端的例子,一個 mutex 保護整個 runtime 的資料,所以使用 CPython 執行 Python script 時,不用擔心 multithreading 會有 data race,但也無法用多個 threads 處理 CPU bound,因為同一時間只有一個 thread 執行。

遵從 KISS (Keep It Simple and Stupid) 原則,你可能會先用一個 mutex 保護所有資料。這個策略OK,只是發覺需要加 mutex 拆開保護對象時,釐清資料分組要花費不少力氣。日後新增資料仍得思考要重用已有的 mutex 或使用新的 mutex。

問題二: 是否允許 Reentrant Lock

Reentrant lock 指可以在同一個 thread 內對同一個 mutex 呼叫兩次以上 lock。有許多最佳實踐建議不允許,理由是這樣會逼開發者想清楚程式執行的情況。不允許 reentrant lock 的情況下,在同一 thread lock 兩次會 deadlock 或是讓程式掛掉。這樣開發者可以早期發現實際執行情況和他想的不同。參考 Chrome C++ Lock and ConditionVariable 的 “Why can the holder of a Lock not reacquire it?” 了解細節。

聽起來很合理,為什麼不照著作?最簡單的使用 mutex 方法是在函式的開頭呼叫 lock,離開時呼叫 unlock。不同語言提供不同語法讓開發者安心不會漏呼叫 unlock (C++ 用 RAII + local object 使用 lock/unlock; Java 的 finally; Go 的 defer)。

用 Go 會這麼寫:

func (obj *MyClass) foo() {
obj.mutex.Lock()
defer obj.mutex.UnLock() // Called after leaving foo()
...
}
func (obj *MyClass) bar() {
obj.mutex.Lock()
defer obj.mutex.UnLock() // Called after leaving bar()
...
}

依這個方式實作,在函式呼叫函式時會踢到鐵版:

func (obj *MyClass) doSomething() {
obj.mutex.Lock()
defer obj.mutex.UnLock()
...
obj.foo() // Oops, reentrant lock!!
...
}

於是得改成這樣:

func (obj *MyClass) doSomething() {
obj.mutex.Lock()
defer obj.mutex.UnLock()
...
obj.mutex.Unlock()
obj.foo()
obj.mutex.Lock()
...
}

程式會變得亂一些。

問題三: 避免 deadlock

使用 mutex 一陣子後,會遇過這樣的問題: 在持有 lock 的期間呼叫 callback,在某些情境下,callback 呼叫了一串函式,最後又回來呼叫同一物件其它函式,導致 deadlock (假設不允許 reentrant lock)。

或是 callback 呼叫一串函式後,回來呼叫同一物件其它函式,取得同一物件的另一個 lock。於是可能造成一個呼叫會依序取得物件的 lock A、B; 若有另一個呼叫是執行期間會依序取得 lock B、A,在不同 thread 同時呼叫兩者時,可能導致 deadlock。

解法是在呼叫 callback 前一定要釋放 mutex,並且 callback 帶的參數不能有內部資料的指標/參考。

綜合以上,可知使用 mutex 要注意太多事項了。參考 Chrome C++ Lock and ConditionVariable 了解更多資訊。

Message Queue

Mutex 實在太難用了,有什麼簡單的替代方案嗎?

有的,簡單的 producer/consumer 模式提供另一種思考方向。假設我們有個 thread-safe queue (例如用 mutex 保護 queue 的全部操作),可以在不同 thread 產生資料傳到 queue 裡,然後在固定的 thread 執行 consumer。只有一個 thread 處理資料,主程式就不用 mutex 保護資料。

Chrome 的 TaskRunner 是一般化的 message queue,message 是 closure,換句話說,可以在指定的 TaskRunner 執行任何物件的方法 。呼叫者要保證該物件的生命週期,避免在執行前物件已被刪除。Chrome 的 TaskRunner 設計的很漂亮,有良好的防呆措施,有興趣的人可以看原始碼的介面呼叫程式的例子。同樣的介面也出現在 Android、iOS 裡。

Active Object Pattern

Active Object Pattern 是一種特定使用 message queue 的方法。Active Object 的所有 public methods 都是 wrapper,用來將資料放入內部 work queue。內部的 worker thread 不斷從 work queue 取工作執行。

wrapper 回傳 future 物件讓呼叫者得知是否已執行完,或是取得回傳值。 promise 和 future 物件是非同步程式用的模式,兩者都保證未來會取得計算結果,其中 promise 允許設定一次結果,future 是唯讀的。要留意的是,不同語言對兩者的定義不同, JavaScript 的 promise 在多數語言裡對應的語意其實是 future。

Go 內建的 channel 很方便,用來作 future 和 work queue 都很直覺。channel 的功能和 synchronous/asynchronous queue 一樣,但 Go 提供語法讓 channel 用起來比較簡單。

下面是用 Go 實作 Active Object Pattern 的例子:

ActiveObject 的 AddOne() / AddTen() / GetSum() 都是 wrapper,傳遞資料到 work queue (workChan),然後由 ActiveObject 的 main() 不斷地執行 work queue 內的工作。

執行結果:

<nil>
<nil>
21

Active Object Pattern 要讀取內部不適合複製的資料時會比較麻煩,要傳入 callback,然後在 worker goroutine 內呼叫 callback。若太常需要讀取內部資料,可能不適合用 Active Object Pattern。視情況或許用 mutex 比較適合。附帶一提,沒什麼 lock contention 的情況,使用 mutex 的時間成本很低。可以盡情使用。

結語

Active Object Pattern 避開一堆使用 mutex 要考慮的問題,達到「本來無一物,何處惹塵埃」的境界。但是有幾個缺點:

  • 需要 thread-safe queue 和 future 輔助。程式語言提供的工具愈方便,實作愈輕鬆。雖說 thread-safe queue 可用來作 future,感覺比較笨重一點。
  • 需要讀取內部資料時,必須用 callback 在 worker thread 內讀資料,使用上比較不便。

作好基本工具後,第一點不是問題; 第二點則是設計時要想清楚的取捨。有多種 Active Object 需要互相讀內部資料時,適合讓它們共用同一個 worker thread,如同絕大多數 GUI framework 的作法: 所有物件必須在 UI thread (通常是 main thread) 執行,只是 GUI framework 沒有像 Active Object Pattern 提供 wrapper,而是呼叫者留意必須在 UI thread 呼叫。

對提供服務的實作者來說,要求呼叫者在正確 thread 呼叫比較省事,可配合檢查 thread 的機制抓出不正確的使用方式,實例有 Chrome 的 ThreadChecker。概念很簡單,在 debug build 的情況下,第一次呼叫時記下 thread id,之後呼叫時檢查是否用同一個 thread 呼叫,不是的話就結束程式並 dump backtrace。

--

--