在這個網路盛行的時代,程式中常常需要連網路取用外部資料,並且不希望因此阻礙主程式的活動,這時會使用非同步操作。或是程式中途需要使用者輸入資料、決定繼續或取消等,若不希望阻礙主程式,也會用非同步操作。
以連上外部伺服器為例,介面可能像是這樣:
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 省去自行詢問的麻煩。
- 可以即時通知 caller 繼續工作,適合要求反應速度的程式。
- 隱藏 thread 的概念,適合更廣的開發者使用。像 iOS 官方文件建議盡量不要使用 thread。
代價是 caller 需要記下呼叫當下的 context,callback 時才知道怎麼完成剩下的工作。並且不方便閱讀以及用 debugger 追踪程式。
以續傳上傳為例,假設網站提供的介面如下:
- /create/?filename=X&size=Y: 得知上傳檔名和大小,回傳檔案代碼
- /upload/?filehandle=X&data=Y&offset=Z: 持續接收檔案內容
- /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 定義了 NSURLConnectionDelegate 和 NSURLConnectionDownloadDelegate。
- 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 只會偶而用到的話 (比方說登入伺服器),到是沒什麼問題。
第二個作法難讀難寫,也因此有些善心人士包好函式庫負責做這類事,像是 libevent、libev。
雖然使用函式庫降低第二種作法的實作難度,程式碼還是不易閱讀。於是有人結合 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 的角度。兩者需要考慮的事不同。特別是自己一手包辦整個程式時,切清楚兩者的角色才會有清楚的架構,方便日後維護。
備註
- 淺談coroutine與gevent:有關於兩者更多的說明。
- The C10K problem:介紹實作承載 concurrent 10,000 連線的 server 程式時的相關技術。如今函式庫遍地開花,使用 gevent 可以輕鬆達到這要求,不過需要另外調一下 OS 參數放鬆一些 process 的限制 (如可使用的 fd 數量)。
- Linux 上的 non-blocking flag 不適用於檔案。可考慮用 thread pool 處理檔案讀寫。