這是一個建立於 的文章,其中的資訊可能已經有所發展或是發生改變。
之前關於調度器的對比分析的文章,在結束時遺留了一些問題:當系統出現高並發的IO訪問時,如一個網路伺服器通常要並發處理成百上千的連結,每個連結可能都是由一個使用者任務執行的,那麼將會出現大量阻塞的IO操作,如果為每個阻塞操作都單獨分配一個OS線程,那麼系統很容易就會退化成多OS線程的系統,輕量任務的優勢將無從談起。本文試圖回答這個問題,通過分析Go和Erlang對於IO、特別是網路IO的最佳化機制,瞭解其對調度器乃至整個系統效能的影響。
Go的IO最佳化機制 —— netpoller
由於Go是一門主要面向互連網環境的分布式語言,相對於一般的IO,如檔案讀寫等,網路IO的並發效能更加重要。對於一般IO,Go的處理方式就是按上篇所說的,將執行Syscall的OS線程剝離。通常應用情境下,不會出現大量並發Goroutine去同時讀寫檔案的情況,因而上面的方式並不會真正造成調度器的退化。因此主要的IO最佳化都是針對io/net庫的。
無專屬偶,Erlang在實現上同樣對網路IO提供了不同於一般IO的高效處理方式,後面再作介紹。
Go實現中利用了OS提供的非阻塞IO訪問模式,並配合epll/kqueue等IO事件監控機制;但是為了彌合OS的非同步機制與Go介面的差異,Go在其庫中做了一些封裝,並在runtime層提供了一種叫做netpoller,“網路輪詢者”的機制,來實現網路IO最佳化。具體來說:
- 首先,無論何時,當在Go中開啟或接收到一個連結時,其檔案控制代碼都會被設為
NONBLOCKING模式。(Go語言庫)
- 當調用相應的Read/Write等操作時,無論是否成功,都會直接返回而不會阻塞。當傳回值是EAGAIN時,表示IO事件還沒有到達,需要等待。這時,Go庫函數調用
PollServer的AddFd()將對應檔案控制代碼加入netpoller的監控池,並將當前Goroutine阻塞。(Go語言庫、netpoll.goc中)
- 當系統中存在空閑 P & M (參見這裡) 時,runtime 會首先尋找本地就緒隊列,若其空,則調用netpoller; netpoller通過OS提供的epoll或kqueue機制,檢查已到達的IO事件,並喚醒對應的Goroutine返回給runtime,將其再度執行。(runtime/proc.c:findrunnable())
- 最後,Goroutine再次回到Go語言庫上下文時,再調用Read/Write等IO操作時,就可以順利返回了。(Go語言庫)
Erlang的IO最佳化機制之一 —— “Async Threads Pool”
在Erlang中,所有IO操作都需要以Port驅動的形式提供,所謂Port驅動包含一組C回呼函數,用來響應使用者進程的訪問;使用者進程則通過通用的訊息傳遞機制與Port互動。Erlang虛擬機器會把Port當做一種特殊的任務加以調度。
真正的系統調用,如read/write/flush等阻塞式操作都被封裝在Port的回呼函數之中,當調度器調度執行響應的Port時,就會導致當前的調度器執行線程被OS阻塞,從而影響系統的並行性。
Erlang解決該問題的辦法是提供了一組OS線程作為非同步線程池,阻塞的IO操作(以函數指標形式)會被Port註冊到非同步線程池的操作隊列中。非同步線程則執行迴圈操作,取出當前任務隊列的IO任務並執行阻塞操作。
這種方式類似Go對非net類IO及執行阻塞式Syscall的調度方式:用一個單獨的OS線程去執行阻塞操作。
Erlang的file IO基本上就是以上述方式實現的。
因為 Erlang 將調度器映射到一個OS線程而說其調度是1:1的其實是不準確的。基於對阻塞IO的非同步處理及上篇講到的Server Load Balancer機制,使得Erlang實際上也實現了M:N的調度,只不過Erlang的官方文檔並沒有這麼說,只是說單純增加調度器數不會對效能造成影響。
Erlang的IO最佳化機制之二 —— “System Level Activities”
如前所述,無論時Erlang還是Go,都是針對伺服器端設計的語言,因此都提供了不同於一般IO的特殊機制來處理網路IO。
Erlang的做法是提供一種特別的調度單元 —— System Level Activities,來調度非同步IO事件。它的思想和Go的netpoller非常類似:
- 首先,網路連結對應的控制代碼會被設為
NONBLOCKING狀態;
- 一次IO操作如果在響應事件到來前被調用,則會將其等待的事件註冊到Erlang虛擬機器的IO事件鏈中;
- 調度器在調度時,會周期性的調用
check_io操作來檢查登入的IO事件是否已經到來(利用OS的poll操作),並喚醒響應事件阻塞的使用者任務(進程或Port)。
值得注意的是,Erlang虛擬機器在處理IO事件時,還採用了一種 stealing 的機制。具體來說,當一個driver的函數調用IO操作時,如果對應IO事件沒有到來時,還會主動調用 select_steal()竊取其他登入的IO事件,如果該事件已觸發,則完成相應的讀/寫操作,並通知上層進行後續處理。
Libtask中的非同步IO機制
作為Go語言的前身,Libtask庫同樣實現了非同步IO機制,並且實現方式更加簡潔。
與Go類似,在Libtask中,為使用者級task封裝了IO操作,提供了fdread/fdwrite/fdwait/fdnoblock等介面實現非同步IO。(在libtask提供的例子中,所有IO操作都是針對網路IO的,因此僅就網路IO情況加以分析。)
- 連結控制代碼首先會通過調用
fdblock()被設為NONBLOCKING態;
- 之後調用
fdread/fdwrite時,一旦返回EAGAIN,則調用fdwait,註冊等待IO事件並將自身調出;
- Libtask會在第一次接收到IO事件註冊後建立一個系統任務
fdtask,該任務通過調用poll系統調用檢查新到來的IO事件,並將對應任務重新加到就緒隊列中。
總結及參考
通過上文分析,瞭解到IO最佳化對調度器乃至語言本身效能的影響。這與兩種語言的應用背景——伺服器端編程有很大關係。
通常來說,應用程式必須通過Syscall 訪問操作的特定功能,這就會涉及底層 OS 的調度機制,作為使用者態的任務調度器,Erlang虛擬機器或Go的運行時系統都必須對核心調度引入的不確定性加以控制。特別是 IO 操作這類特殊並且會大量訪問到的Syscall,必須設計有針對性的最佳化方案,才能確保高的並發效能。
Go 和 Erlang 的實現方案隨不盡相同,但核心的思想都是類似的,通過非同步IO 最佳化基於Socket的操作,而對於一般的檔案讀寫,則直接讓執行線程及啟動並執行使用者任務阻塞,調度器再將其他可以執行的任務綁定到其他OS線程繼續執行。
這篇文章除了參考了Erlang/OTP及Go語言的原始碼外,還參考了以下資料:
- Morsing “The Go netpoller” http://morsmachine.dk/netpoller
- Ramblings “How Erlang does scheduling” http://jlouisramblings.blogspot.com/2013/01/how-erlang-does-scheduling.html