用戶態併發:事件驅動

上篇末尾有個地方說錯了,分時調度的yield過程應該是: 
env.running_queue.add(this); 
this.stat = STAT_YIELD; 
return; 
需要將this加入到running_queue,否則這個線程就死了 

有了分時調度,就可以實現計算密集型的程序的併發執行,不過絕大多數程序顯然不是這種,程序多多少少都會進入阻塞等待,比如IO,鎖,sleep等,這些我們統一認爲是事件,阻塞實際就是在等待事件 

簡單說,用戶態的事件驅動,就是建立一個事件表,其中每個事件有一個等待的線程列表,由虛擬機主循環檢測事件是否觸發(上篇代碼中註釋部分有),如果觸發則調度到等待線程,具體調度方式很簡單,移動到running_queue即可,具體的,事件可分爲以下幾類: 

一、虛擬機內部產生的事件 
這類事件是虛擬機執行時,內部一些機制產生的,這類事件一般也不用在虛擬機主循環裏面主動檢測,發生的時候直接操作env,將需要調度的線程加入running_queue即可,最典型的例子上篇已經實現了,線程的join等待是在被等待線程結束的時候主動通知所有在等待的其他線程。其餘的類似事件還有信號量等內核對象(這個內核當然指虛擬機) 
P.S.關於線程join這個問題,上篇的實現還是有問題的,比如A線程先結束,然後B線程join A,應該拿到結果,因此上篇的實現還是簡單了,線程的結果應該存在一個地方供其他線程join,join的時候如果被join的線程已經結束,則應立即返回,否則阻塞等待事件 

二、定時器事件 
某個線程在運行時如果需要暫停一段時間,則需要等待定時器事件,具體的做法是弄一個定時器隊列(一般是優先隊列,hash輪等算法),當一個線程需要暫停時,增加一個定時器,yield,然後在虛擬機主循環中檢測當前已超時的定時器,並調度到對應線程。定時器的一個問題是,由於虛擬機本身運行比較慢,分時調度的粒度也可能比較大,因此不是很精確,當然os的sleep實際也不精確,這個問題控制在可接受範圍之內即可 

三、中斷事件 
一般意義的中斷一般是指硬件層到操作系統的,這裏討論虛擬機,這類事件就只有一種了:信號。如果虛擬機進程收到一個信號,比如SIGINT,虛擬機自己是不用負責信號的業務處理的,而是由源語言註冊信號handler,但是虛擬機需要保證信號不能打斷正常運行,具體的做法,在收到信號後,虛擬機可以記錄在一個待處理信號表中,然後在主循環中檢測,如果檢測到上個虛擬時間片收到了信號,則調度執行主線程,並且讓主線程立即去執行對應的handler(不能虛擬機自己直接調handler,因爲handler裏面要用到主線程環境) 
可能有人有疑問,爲何一定要在主線程執行,實際上不同平臺對於多線程程序收到信號的實現是不一樣的,有的平臺是打斷主線程,有的是隨機deliver給一個線程,在具體的虛擬機中,設計爲由主線程處理相對合理一點 

四、阻塞IO事件 
當某個線程需要操作低速IO(高低速IO的概念參考APUE),比如socket的讀寫等,可能阻塞住,這種情況下,就需要線程在可能阻塞的時候先yield,在IO事件到來以後再繼續執行,這個做起來有點複雜: 
首先,所有的可能阻塞的IO操作都需要改成非阻塞的方式,比如從socket收數據: 
data, error += recv_from_socket(s, need_len); 
if (error == EWOULDBLOCK) 
{ 
    //這裏的t是當前線程,類似系統調用的接口不推薦實現在Thread類裏面 
    env.io_event_table.add(t, s, EVENT_TYPE_READ); //註冊讀事件通知 
    ... //yield; 
} 
然後,在虛擬機的主循環中需要對所有IO事件做監控,IO事件來自於底層操作系統,一般可以用select或epoll來監控,若事件到達,則調度至對應線程 

對於IO事件,其實像上面這麼實現還是比較麻煩的,如果從另一個角度考慮,IO操作是一個系統調用,而對虛擬機上跑的程序而言,所謂“系統”其實就是虛擬機,因此可以在虛擬機上做一個代理層,應用層的IO請求由代理層完成,然後返回給應用層,這樣代理層就可以做一些合理的包裝,比如: 
send(s, data); 
假設這是一個在os的系統調用,則一般實現爲當前能發送多少數據就先發送多少數據,send返回值爲實際發送量,而我們實際上是希望將data都發出去,如果暫時發不出,就阻塞,則我們需要一個sendall: 
void sendall(Socket s, String data) 
{ 
    while (data.size() > 0) 
    { 
        int send_len = send(s, data); 
        data = data.substr(send_len, data.size()); 
    } 
} 
代理層可以將這個做了,和手工實現sendall的區別是,虛擬機在send可能阻塞時會自己調度。類似的還有recvall等需求,可以給源語言的程序設計帶來一些便利。具體實現上就是,一個線程告訴虛擬機,我要發送(或接收)xxx字節的數據,然後虛擬機幹完這事(或中途出錯)後將結果通知線程,由虛擬機代理IO,當然代理的方式可以自由設計,比如另開一個真線程來搞。另開一個IO真線程可以減少虛擬機設計的耦合度,數據收發超時等也容易實現,不至於和虛擬機自己的定時器混在一起 

解決上述四個事件驅動後,功能上基本就全了,但還有一些很重要的細節問題: 

一、虛擬機本身也是需要阻塞的,否則一個程序跑起來CPU就100%,大部分時間都在輪詢檢查事件,肯定讓人無法接受,虛擬機的阻塞需要跟源語言的阻塞邏輯一致,具體的,只有當所有線程都進入等待事件狀態時,虛擬機阻塞,阻塞的實現一般是在select或epoll中,阻塞時間則根據最近一個定時器來決定 

二、由於線程join,信號量等機制是在虛擬機層模擬操作系統實現(爲提高效率),對應的一些死鎖檢測也必須虛擬機來做了,檢測算法參考操作系統原理即可 

三、驚羣效應,如果多個線程等待同一事件,則需要根據具體情況決定是全部喚醒還是喚醒一個,比如線程結束事件應該廣播通知所有正在join的線程,而互斥鎖的解鎖事件則只能通知一個正在等待的線程 

四、和上面第三條相反,有時候一個線程在同時等待多個事件,最典型的例子是,當一個線程recv或send的時候,指定一個超時時間,或者等待一個鎖的時候指定超時時間,或者源語言提供了select之類的接口,監控多個IO事件 
如果多個事件是衝突的,可以通過事件關聯來做,比如加鎖超時的需求,同時註冊鎖對象的等待和定時器,在事件管理器裏面用一個group將兩者關聯,只要其中一個觸發了,則從另一個的等待隊列刪除對應線程,避免出問題 
如果多個事件不衝突,比如select,我們希望在select返回時儘可能多的得知當前已準備好的IO事件,則可能需要在檢測了所有事件後進行彙總,然後再根據事件關聯來調度線程 
不過話說回來,如果一個語言是用戶態調度的,則select一般用的也比較少,因爲可以每個IO事件開一個線程來監控,反正用戶態的線程非常廉價,如果多個IO事件之間有關聯,也可以通過線程間通訊來實現(虛擬機可以實現類似的阻塞管道,並和IO事件綁在一起) 

這裏的三四還可能組合在一起,比如一個線程正在對socket sa,sb的可讀做select,另一個正在讀sb,那麼sa,sb同時可讀時該怎麼通知呢,這可能就是一個實現相關的問題了,不過,一個socket被兩個線程同時操作,本身就可能產生未定義行爲,一般都是需要避免的 

其實我原本沒想寫併發相關的內容,因爲大部分都是跟操作系統相關,不如去看操作系統原理,使用GIL等技術,在語言實現中實現多線程也不困難。但是多線程環境可能會導致GC之類的機制的複雜性,而類似的方面我打算只在單線程的前提下討論,因此講了用戶態併發,語言實現併發完全可以不依賴宿主的多線程環境,這也算是語言實現的一種簡化方案了
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章