2013年6月29日 星期六

非同步程式心得

在這個網路盛行的時代,程式中常常需要連網路取用外部資料,並且不希望因此阻礙主程式的活動,這時會使用非同步操作。或是程式中途需要使用者輸入資料、決定繼續或取消等,若不希望阻礙主程式,也會用非同步操作。

以連上外部伺服器為例,介面可能像是這樣:

bool LoginToServer(const std::string& name,const std::string& password);
void OnLoginToServer(const std::string& name,int error);

主程式呼叫 LoginToServer() 的當下只能由回傳值知道有沒有進行登入,但不知道是否登錄成功。過一段時間後,某個 thread 會呼叫 OnLoginToServer() 透過 error 的值告知主程式登入成功或失敗。

非同步溝通介面

以下以 caller 和 async module 分別表示「使用非同步 API 的函式」以及「提供非同步 API 的模組」。async module 提供的 API 有兩種選擇:

  • 使用 callback 的方式,async module 有結果時通知使用 callback 通知 caller。
  • caller 事後自行詢問先前的呼叫是否已有結果。

兩種介面各有優缺點,不過 callback 似乎比較盛行?

使用 callback 的方式

async module 會保證通知 caller,這有以下的好處:

代價是 caller 需要記下呼叫當下的 context,callback 時才知道怎麼完成剩下的工作。並且不方便閱讀以及用 debugger 追踪程式。

以續傳上傳為例,假設網站提供的介面如下:

  1. /create/?filename=X&size=Y: 得知上傳檔名和大小,回傳檔案代碼
  2. /upload/?filehandle=X&data=Y&offset=Z: 持續接收檔案內容
  3. /done/?filehandle=X: 結束上傳

client 使用 http 函式庫時,需要注意以下事項:

  • 需要類似 state machine 的結構,callback 時才明白目前進行到那一個步驟。
  • 需要記錄輔助資訊,像是目前在處理那個檔案,上傳到那一段資料。
  • 需要考慮中途的錯誤處理。
  • 結束後和主程式接軌的方法
  • 同時上傳多個檔案時,儲存狀態的資料結構需要更靈活一些。

使用詢問的方式

async module 可以提供 non-blocking 或 blocking 的詢問函式。使用 pthread_cond_wait() 之類的方式避免 busy waiting。於是 caller 可使用 non-blocking 的方式確認情況,或是使用另外特定的 thread 用 blocking 的方式確認情況。使用 blocking 的詢問方式時,caller 不需另外存下呼叫時的狀態,易寫易讀。

但是有多個非同步操作時,詢問的方式不容易即時延續先前非同步的操作。比方發出十個 http request 時,若是使用 callback 介面,那個 http response 好了,就先呼叫它的 callback 傳回 http response;使用詢問方式時,caller 得一個個用 non-blocking 詢問方式,或十個 thread 各用 blocking 詢問的方式,才能達到一樣的反應速度。

callback 介面的選擇

async module 提供 caller 傳入 callback 時,有幾種選擇:

  • async module 定義 class APIDelegate,caller 使用 async module 前放入 async module 規定的 APIDelegate,async module 之後會呼叫 APIDelegate 不同函式告知後續情況。比方說 iOS 的 NSURLConnection 定義了 NSURLConnectionDelegateNSURLConnectionDownloadDelegate
  • caller 呼叫 async module 的函式時,直接傳入 callback object,像 JavaScript 的 jQuery.ajax() 裡面的 success 以及 error 參數。

第一種介面明確,易讀難寫;第二種則是易寫難讀。若是長期發展的程式,我偏好第一種設計,有明確的名稱比較好追蹤程式碼。

不論是那種介面,caller 都需要自行記住呼叫當下的狀態,callback 回來時才知道如何完成後續工作。像 NSURLConnection 的 callback 只有告知 connection,connection 又不能作為 dictionary 的 key,使用上稍有不便。理想的情況下,callback 應該提供 context 帶有必要的資訊或是提供 unique ID 方便 caller 自行管理 context。比方說 Unix 的檔案操作提供一個 file handle,由系統記住檔案相關的設定和讀寫的狀態,caller 透過 file handle 可以取得所有相關資料。

async module 實作的選擇

實作 async module 有幾種選擇:

  • 新增一個 thread 讀寫外部資料,結束後呼叫 callback。
  • 使用 non-blocking I/O 讀寫外部資料,再由一個特定的 thread 持續追蹤結果,有結果後再呼叫 callback。由於可以使用系統函式 (如 select 或 epoll) 追蹤,負擔相對的低。

第一個作法易讀易寫,但是有以下的缺點:

  • 新增和消滅 thread 有時間成本。
  • thread 會占用額外記憶體。若一個 thread 占用 2MB,512 個會占用 1GB。
  • 大量 thread 同時讀寫資料時,增加額外 context switch 的成本。

若這個 module 只會偶而用到的話 (比方說登入伺服器),到是沒什麼問題。

第二個作法難讀難寫,也因此有些善心人士包好函式庫負責做這類事,像是 libeventlibev

雖然使用函式庫降低第二種作法的實作難度,程式碼還是不易閱讀。於是有人結合 coroutine 和 non-blocking I/O,做出更好用的框架。我目前只有用過 Python 的 gevent,寫起來如同寫 multi-thread,但是底層沒有用到大量 thread,免除了 thread 帶來的缺點。相信在其它語言也有類似的框架。

使用 coroutine 的時機

雖然 coroutine 兼具易寫易讀負擔又低等優點,它有個關鍵的難處:所有程式都要在 coroutine 的架構下,只要有一個 "thread" (正確說法是 subroutine) 卡住了 (比方不小心用到 blocking I/O),全部 "thread" 都會被卡住。現今的程式使用許多外部函式庫,很難保證不會發生這類事。

此外,依實作方式而定,coroutine 可能不方便使用多個 CPU,只要有一個工作需要大量 CPU 計算時間,也會拖累其它 "thread"。

所以一般的 GUI 程式或 client 端連線程式不見得適用 coroutine。但像 proxy server 這類主要工作是 I/O 且相當重視連線數量 scalability 的程式,就很適合用 coroutine

gevent 使用心得

以我自己的情況來說,主程式使用 gevent 處理 client 的需求,需要外部 http 連線時使用 geventhttpclient,滿容易達到 C10K。不過 unit test 的時候有些小問題,還有 gevent signal 處理有些 bug,踩到時要特別處理。

另外,雖然使用 gevent 的 monkey patch 可「無痛替換所有網路操作」,實測的結果效能不太好,而且我個人偏好「Explicit is better than implicit」,所以沒用 monkey patach。

結語

以上是這一年半來寫網路程式和 GUI 的心得。實作非同步程式時,要留意自己是處於 caller 或 async module 的角度。兩者需要考慮的事不同。特別是自己一手包辦整個程式時,切清楚兩者的角色才會有清楚的架構,方便日後維護。

備註