標籤:style blog http color io os 使用 ar for
State Threads——非同步回調的線性實現
原文連結:http://coolshell.cn/articles/12012.html
本文的標題看起來有點拗口,其實State Threads庫就是在單線程中使用同步編程思想來實現非同步處理流程,從而實現單線程能並發處理成百上千個請求,而且每個請求的處理過程是線性,沒有使用晦澀難懂的callback機制來銜接處理流程。
ST (State Threads) 庫提供了一種高效能、可擴充伺服器(比如web server、proxy server、mail agent等)的實現方案。
ST 庫簡化了multi-threading編程範式,每個請求對應一個線程,注意這裡的線程其實是一種coroutine(協程),跟pthread那種核心線程不是一回事。
ST 實現原理
傳統的網路伺服器(如nginx、squid等)都採用了 EDSM (event-driven state machine,事件驅動狀態機器) 機制並發處理請求,這是一種非同步處理的方式,通過使用callback 方法避免阻塞線程。
EDSM最常見的方式就是I/O事件的非同步回調。基本上都會有一個叫做dispatcher的單線程主迴圈(又叫event loop),使用者通過向dispatcher註冊回呼函數(又叫event handler)來實現非同步通知,從而不必在原地空耗資源乾等。在dispatcher主迴圈中通過select()/epoll()等系統調用來等待各種I/O事件的發生,當核心檢測到事件觸發並且資料可達或可用時,select()/epoll()會返回從而使dispatcher調用相應的回呼函數來對處理使用者的請求。
整個過程都是單線程的。這種處理本質上就是將一堆相互獨立(disjoint)的回調實現同步控制,就像串聯在一個順序鏈表上。如,黑色的雙箭頭表示I/O事件複用,回調是個筐,裡面裝著對各種請求的處理(當然不是每個請求都有回調,一個請求也可以對應不同的回調),每個回調被串聯起來由dispatcher啟用。這裡請求等價於thread的概念(不是作業系統的線程),只不過“環境切換”(context switch)發生在每個回調結束之時(假設不同請求對應不同回調),註冊下一個回調以待事件觸發時恢複其它請求的處理。至於dispatcher的執行狀態(execute state)可作為回呼函數的參數儲存和傳遞
非同步回調的缺陷在於難以實現和擴充,雖然已經有libevent這樣的通用庫,以及其它actor/reacotor的設計模式及其架構,但正如Dean Gaudet(Apache開發人員)所說:“其內在的複雜性——將線性思維分解成一堆回調的負擔(breaking up linear thought into a bucketload of callbacks)——仍然存在”。從可見,回調之間請求常式不是連續的,比如回調之間的切換會打斷部分請求,又比如有新的請求需要重新註冊。
ST本質上仍然是基於EDSM模型,但旨在取代傳統的非同步回調方式。ST將請求抽象為thread概念以更接近自然編程模式(所謂的linear thought吧,就像作業系統的線程之間切換那樣自然)。ST的調度器(scheduler)對於使用者來說是透明的,不像dispatcher那種將執行狀態(execute state)暴露給回調方式。每個thread的現場環境可以儲存在棧上(一段連續的大小確定的記憶體空間),由C的運行環境管理。從看到,ST的threads可以並發地線性地處理I/O事件,模型比非同步回調簡單得多。
這裡稍微解釋一下ST調度工作原理,ST運行環境維護了四種隊列,分別是IOQ(等待隊列)、RUNQ(運行隊列)、SLEEPQ(逾時隊列)以及ZOMBIEQ。當每個thread處於不同隊列中對應不同的狀態(ST顧名思義所謂thread狀態機器)。比如polling請求的時候,當前thread就加入IOQ表示等待事件(如果有timeout同時會被放到SLEEPQ中),當事件觸發時,thread就從IOQ(如果有timeout同時會從SLEEPQ)移除並轉移到RUNQ等待被調度,成為當前的running thread,相當於作業系統的就緒隊列,跟傳統EDSM對應起來就是註冊回調以及啟用回調。再比如類比同步控制wait/sleep/lock的時候,當前thread會被放入SLEEPQ,直到被喚醒或者逾時再次進入RUNQ以待調度。
ST的調度具備效能與記憶體雙重優點:在效能上,ST實現自己的setjmp/longjmp來類比調度,無任何系統開銷,並且context(就是jmp_buf)針對不同平台和架構用底層語言實現的,可移植性媲美libc。下面放一段代碼解釋一下調度實現:
/* * Switch away from the current thread context by saving its state * and calling the thread scheduler */#define _ST_SWITCH_CONTEXT(_thread) \ ST_BEGIN_MACRO if (!MD_SETJMP((_thread)->context)) { _st_vp_schedule(); } ST_END_MACRO /* * Restore a thread context that was saved by _ST_SWITCH_CONTEXT * or initialized by _ST_INIT_CONTEXT */#define _ST_RESTORE_CONTEXT(_thread) \ ST_BEGIN_MACRO _ST_SET_CURRENT_THREAD(_thread); MD_LONGJMP((_thread)->context, 1); ST_END_MACRO void _st_vp_schedule(void){ _st_thread_t *thread; if (_ST_RUNQ.next != &_ST_RUNQ) { /* Pull thread off of the run queue */ thread = _ST_THREAD_PTR(_ST_RUNQ.next); _ST_DEL_RUNQ(thread); } else { /* If there are no threads to run, switch to the idle thread */ thread = _st_this_vp.idle_thread; } ST_ASSERT(thread->state == _ST_ST_RUNNABLE); /* Resume the thread */ thread->state = _ST_ST_RUNNING; _ST_RESTORE_CONTEXT(thread);}
如果你熟悉setjmp/longjmp的用法,你就知道當前thread在調用MD_SETJMP將現場上下文儲存在jmp_buf中並返回返回0,然後自己調用_st_vp_schedule()將自己調度出去。調度器先從RUNQ上找,如果隊列為空白就找idle thread,這是在整個ST初始化時建立的一個特殊thread,然後將當前線程設為自己,再調用MD_LONGJMP切換到其上次調用MD_SETJMP的地方,從thread->context恢複現場並返回1,該thread就接著往下執行了。整個過程就同EDSM一樣發生在作業系統單線程下,所以沒有任何系統開銷與阻塞。
其實真正的阻塞是發生在等待I/O事件複用上,也就是select()/epoll(),這是整個ST唯一的系統調用。ST當前的狀態是,整個環境處於空閑狀態,所有threads的請求處理都已經完成,也就是RUNQ為空白。這時在_st_idle_thread_start維護了一個主迴圈(類似於event loop),主要負責三種任務:1.對IOQ所有thread進行I/O複用檢測;2.對SLEEPQ進行逾時檢查;3.將idle thread調度出去,代碼如下:
void *_st_idle_thread_start(void *arg){ _st_thread_t *me = _ST_CURRENT_THREAD(); while (_st_active_count > 0) { /* Idle vp till I/O is ready or the smallest timeout expired */ _ST_VP_IDLE(); /* Check sleep queue for expired threads */ _st_vp_check_clock(); me->state = _ST_ST_RUNNABLE; _ST_SWITCH_CONTEXT(me); } /* No more threads */ exit(0); /* NOTREACHED */ return NULL;}
這裡的me就是idle thread,因為_st_idle_thread_start就是建立idle thread的啟動點,每從上次_ST_SWITCH_CONTEXT()切換回來的時候,接著在_ST_VP_IDLE()裡輪詢I/O事件的發生,一旦檢測到發生了別的thread事件或者SLEEPQ裡面發生逾時,再用_ST_SWITCH_CONTEXT()把自己切換出去,如果此時RUNQ中非空的話就切換到隊列第一個thread。這裡主迴圈是不會退出的。
在記憶體方面,ST的執行狀態作為局部變數儲存在棧上,而不是像回調需要動態分配,使用者可能分別這樣使用thread模式和callback模式:
/* thread land */int foo(){ int local1; int local2; do_some_io();} /* callback land */struct foo_data { int local1; int local2;}; void foo_cb(void *arg){ struct foo_data *locals = arg; ...} void foo(){ struct foo_data *locals = malloc(sizeof(struct foo_data)); register(foo_cb, locals);}
另外有兩點要注意,一是ST的thread是無優先順序的非搶佔式調度,也就是說ST基於EDSM的,每個thread都是事件或資料驅動,遲早會把自己調度出去,而且調度點是明確的,並非按時間片來的,從而簡化了thread管理;二是ST會忽略所有訊號處理,在_st_io_init中會把sigact.sa_handler設為SIG_IGN,這樣做是因為將thread資源最小化,避免了signal mask及其系統調用(在ucontext上是避免不了的)。但這並不意味著ST就不能處理訊號,實際上ST建議將訊號寫入pipe的方式轉化為普通I/O事件處理,樣本詳見這裡。
multi-threading編程範式
Posix Thread(以下簡稱PThread)是個通用的線程庫,它是將使用者級線程(thread)同核心執行對象(kernel execution entity,有些書又叫lightweight processes)做了1:1或m:n映射,從而實現multi-threading模式。例如,Apache伺服器就是使用了PThread來實現並發請求的處理,每個線程處理一個請求,線程是以同步、阻塞的方式處理請求的,線上程的當前請求處理完成之前不會接受其它請求。
而ST是單線程(n:1映射),它的thread實際上就是協程(coroutine)。通常的網路應用上,多線程範式繞不開作業系統,但在某些特定的伺服器領域,線程間的共用資源會帶來額外複雜度,鎖、競態、並發、檔案控制代碼、全域變數、管道、訊號等,面對這些Pthread的靈活性會大打折扣。而ST的調度是精確的,它只會在明確的I/O和同步函數調用點上發生環境切換,這正是協程的特性,如此一來ST就不需要互斥保護了,進而也可以放心使用任何靜態變數和不可重新進入庫函數了(這在同樣作為協程的Protothreads裡是不允許的,因為那是stack-less的,無法儲存上下文),極大的簡化了編程和調試同時增加了效能。
這裡順便說一句,C語言實現的協程據我所知只有三種方式:
1、Protothread為代表利用switch-case語義跳轉;
2、以ST為代表不依賴libc的setjmp/longjmp環境切換;
3、依賴glibc的ucontext介面(雲風的coroutine);
其中,Protothread最輕,但受限最大,ucontext耗資源效能慢,目前看來ST是最好使的。
總結
ST的核心思想就是利用multi-threading的簡單優雅範式勝過傳統非同步回調的複雜晦澀實現,又利用EDSM的效能和解耦架構避免了multi-threading在系統上的開銷和暗礁。
ST的主要限制在於,應用程式所有I/O操作必須使用ST提供的API,因為只有這樣thread才能被調度器管理,並且避免阻塞。
其實最後在羅嗦一句,ngx_lua模組也是利用coroutine簡化了Nginx流程的處理流程,每個請求對應一個lua coroutine,從而在coroutine內部完全使用線性方式處理請求,避免了使用回調的非同步寫法;
參考文檔:
http://state-threads.sourceforge.net/
http://state-threads.sourceforge.net/docs/faq.html
State Threads——非同步回調的線性實現