2015年9月17日 星期四

從 WaitForMultipleObjects() 談 Windows IO 模型的失敗

WaitForMultipleObjects() 與 select() 的功能很類似,都是用來等待多個 handles 上的事件發生(I/O、mutex、semaphore、IPC...)。站長在這兩個平台也打滾一段時日了,兩個 API 也都蠻熟的,從本篇的分析你將會發現到 Windows I/O 模型為什麼失敗,為什麼很多知名 open source project 沒有 Windows 版本,或是執行效能遠不如 Linux。

掃描 handle 數的上限

WaitForMultipleObjects() 的上限非常不可思議,64!即使到了 Windows 10,他的上限還是 64! 但 select() 至少是 256-1024(甚至還可以更大),對於一些較具規模的應用來說很容捉襟見肘。

界面的一致性

WaitForMultipleObjects() 64 個 handle 上限也就算了,但 winsock 又搞了一個跟 UN*X select() 很像的 select(),但是只能用在 winsock 上,界面的不統一讓開發者無法把 Asynchronous I/O 的相關程式碼整合在一個 API 呼叫(微軟硬要把 Asynchronous I/O 取一個新名子叫 overlapped I/O),變成要用兩個 thread 進行通訊,增加複雜度與不確定性。

相反的,UN*X select() 繼承 UN*X 大一統設計哲學,對所有傳入的 file descriptor 一視同仁,無倫這個 fd 是 socket file handle、還是 pipe、file I/O、開發者可以很容易整合 Asynchronous I/O 相關程式碼。

到了 winsock 2.0 微軟又搞出一個 WSAWaitForMultipleEvents(),這個 select()-like API 還開倒車,能夠等待的 handle 數降為跟 WaitForMultipleObjects() 一樣 => 64!不過這次微軟總算做好一件事,就是這個 API 在「目前」的實做中是呼叫 WaitForMultipleObjectsEx(),也就是你終於可以用單一 API 把 overlapped IO 整合到同一個 API call 了。
(不過要是未來微軟改變 WSAWaitForMultipleEvents() 的行為,那就只能自求多福了!)

到了 Vista,微軟第三次出擊,開發了一個 WSAPoll(),這個罕為人知的 API 下場如何呢?

Python: rejected
cURL: 作者寫了落落長的文章批評 WSAPoll()

WSAPoll() 犯的錯誤就是企圖模仿 UN*X poll(),希望開發者把使用 poll() 的 UN*X 網路程式移植到 Windows,但是行為又不一致!其中最糟糕的問題是,如果使用 WSAPoll() connect() 一個 IP address 上不存在的 listen port,你設定的 timeout 多長就會等多長。而且微軟不認為這是 bug 拒絕去修復他!

Overlapped I/O 難以正確使用

如果您覺得 select() 很難上手,那您應該看一下 overlapped I/O 有多難用。select() 把 read/write 區分為不同的集合(fd_set),但 overlapped I/O 則是全部混在一個陣列中(WaitForMultipleObjects 的第二個參數)。

這樣當 WaitForMultipleObject() return 時,區別 read/write event 就會需要許多額外的程式碼來輔助。在微軟的範例中,read/write 都是用同一個 event handler,然後用額外的變數去紀錄上一次發起的 I/O request 是 read or write,否則將會不知道是 read 還是 write 成功。

但是對於 select() 來說這件事很簡單,select() 的用法如下:
//...
fd_set read_set = ref_read_set;
fd_set write_set = ref_write_set;
int rc = select(maxfd+1, &read_set, &write_set, &except_set, &timeout);
//...
對於 select()-based 程式來說,他只要 FD_ISSET(fd, write_set) 就知道之前發起的 write request是不是被 OS 接受了。要追蹤是否曾經發起過 write request 也是一樣 FD_ISSET(ref_write_set)。

對於 overlapped I/O,要追蹤是否發起 read or write request 一樣只能靠自己定義變數追蹤,在 OS 層面沒有提供任何解決方案。

另外一個重大的不同是,overlapped I/O 讀寫的資料是存放在 user space - 也就是您剛剛發起的 read/write request 參數中的 buffer(例如:ReadFile() 的第二個參數)。以 read request 來說,當 read request 被 OS 接受取得資料由 WaitForMultipleObject() 返回後,您不用再次呼叫 ReadFile() 來從 OS buffer 中拷貝資料,資料已經幫您複製好了。

相較 select() 來說,這減少了資料拷貝的動作,當然效率會比較好,但也增加了程式的複雜度,要額外追蹤的東西變多了。

WaitForMultipleObjects() 陷阱

WaitForMultipleObjects() 有一個很少人知道、使用上的陷阱。MSDN 上跟你講在呼叫 API 時第三個參數 bWaitAll 假如傳入 FALSE,則當第一個參數 handle array 中某些元素被觸發時,API 會回傳元素中索引最小的那個。

舉個例子,現在有 A, B, C 三個 semaphore,分別存入 handle array[0], [1], [2],為了簡化問題起見,這三個 semaphore 都是 binary semaphore(只有0/1)。此時若 B, C 觸發了(=1)。WaitForMultipleObjects() 會回傳 index 1(WAIT_OBJECT_0 + 1)。

現在 A, B, C semaphore 實際狀態是:

A: 0
B: 0
C: 1

微軟並沒有告訴我們 C 該如何處置,但是按照一般的思維脈絡,只要再次呼叫 WaitForMultipleObjects() 就行了(寫成無窮迴圈)。但深入思考就會發現,如果每次都是按索引大小順序處理,那 B 頻繁觸發的話你可能永遠沒有機會處理 C(如果 A 頻繁觸發就沒有機會處理 B, C)。

正確的處理方式是 WaitForMultipleObjects() return 後,用 WaitForSingleObject(handles[i], 0) 處理餘下的 handles。這看起來似乎跟 select() 有點像,但不同的是 WaitForMultipleObjects() return 的那個 index 指向的 handle 已經被處理過了。

所以在上面的例子中,B = 0, C = 1,你必須小心翼翼的處理這個 index 與「其他」,這種把 edge trigger 與 level trigger 混在一起的方式不但徒增困擾,也增加了程式的複雜度與 bug 的出現率。

epoll() vs. IO Completion Port

IO Completoion Port(IOCP) 的想法是,thread 數要跟 CPU 核心數成某種比例才有意義,否則 thread 太多造成 OS 瘋狂 schedule,效率反而下降。這些固定數量的 threads 被視為一種 thread pool,當 IO queue 有資料近來時,就會叫起其中一個閒置的 thread。

epoll() 則是 Linux 對 select() 的一次大改進,在之前 select() 回傳時無法得知哪些 handles 被觸發了,得用 FD_ISSET() 一一檢查,這次 epoll() 會直接回傳一組陣列,這個陣列就是被觸發的
物件集合,程序員只要針對這個陣列處理,大大減輕工作量(包含 CPU 與程序員)。

結論


從流行的網路伺服器軟體來看(Nginx, node.js...),event driven 這一邊取得了勝利。為什麼呢?做個簡單的列表您就知道了

IOCP:

  • 設計: 難
  • 除錯: 難

Event driven:

  • 設計: 難
  • 除錯: 容易

因為 IOCP 還是沒有拋棄 multithreading 的想法,而且把原本的 multithreading 弄的更複雜,原本一個 connection 配給一個 thread 這種設計很直觀易懂,轉換成 IOCP 後變成一個 thread 「身兼多職」,除錯難是只要有 thread 就有 synchronization、race condition 的問題要處理,而設計變複雜對於除錯的難度提高搞不好還有加乘的效果XD。

對於 single process,event driven 的程序來說,最困難的地方就是要設計 state machine,但是到了測試階段則遠比 multithreading 輕鬆許多,也不用煩惱現在到底現在到底身處哪一個 thread,也不用擔心 multithreading 特有的 Heisen Bug,傳統的 debugger tool 也更使得上力。

所以不知道各位有沒有發現?微軟並不喜歡你直接去操作 socket,最好是使用他們的伺服器軟體使用包裝過的超高階程式語言,如此一來就能隱藏他們 IO 模型的先天缺陷。

Linux 則是告訴我們,即使使用古老的 C 你也能好好做事,並不一定需要 virtual machine、garbage collection、Intelligent IDE...一堆奇門遁甲的玩意

Linux 萬歲!

沒有留言:

張貼留言