用戶態併發:基本框架

前述關於線程的棧大小問題,其實棧是可以動態增長的,只不過爲了效率問題,一般都是固定的,這是一個實現相關,並非線程的原罪;不過說的第二點,線程調度需要陷入內核,這個的確非常影響效率。而協程沒有這兩個問題,首先所有協程本質是可以在一個線程裏面執行,一個協程切換的時候是暫時返回,執行棧都是複用的,隨便開個比較大的空間就行了,協程的狀態在堆上申請,可以按需申請,因此協程可以開很多很多,百萬級都沒問題;另一點,協程的切換不需要進入內核,算法可以實現得非常簡單。但是也如上一篇說的,直接使用協程寫代碼不是很方便,無論語法怎麼改善,至少我們要自己控制yield,光這點就讓人不爽,再者大量的yield有時候還可能讓代碼變得不是那麼好讀,還不如自己用class寫自動機對象,然後事件驅動和回調註冊(twisted之類的框架就這麼幹的) 

爲了結合兩者的優點,需要在語言實現上下功夫,具體地說,就是在寫源語言的時候,可以像很多流行的語言一樣創建線程,使用方式也一樣,但源語言的線程並不對應宿主環境的真線程,宿主環境可以自始至終單線程執行,在執行時對源語言的邏輯線程進行調度,具體實現跟協程和線程都有相似的地方,簡單說就是在虛擬機實現一個簡化版的小型操作系統,只不過這個os主要作用是調度,其他細節如硬件驅動和內存管理則直接使用宿主環境了。因此本文討論的是在虛擬機環境下的用戶態線程併發,跟宿主環境的真實線程沒有關係,可以叫僞線程,下述代碼和描述中的Thread和線程都是指僞線程。另外,簡單起見我們忽略異常機制等細枝末節的實現 

這個虛擬機仿照操作系統相關部分來做,跟前面講過的一個例子一樣,虛擬機中所有線程都是對等的,即main線程也需要創建,虛擬機入口是一個調度主循環: 
... //必要的初始化工作 
Thread main_thread = new Thread(code.main_func); //建立主線程,只是個對象而已 
env.thread_table.add(main_thread); //主線程加入線程列表 
env.running_queue.add(main_thread); //主線程可以立即開始執行 
while (env.thread_table.size() > 0) //開始調度 
{ 
    ... //這裏需要檢查各種註冊的事件是否ready,下篇再說 
    Thread t = env.running_queue.pop_front(); 
    t.run(); //執行線程 
    switch (t.stat) //檢查線程狀態 
    { 
        case Thread.STAT_FINISH: 
        { 
            //通知正在join它的線程 
            for joining_t in t.wait_join_list 
            { 
                joining_t.event_value = t.result; //告知返回值 
                env.running_queue.add(joining_t); //加入running隊列 
            } 
            thread_table.erase(t); //線程結束,去掉它 
        } 
        case Thread.STAT_YIELD: 
        { 
            continue; //線程主動放棄執行,切換 
        } 
        ... //可能還有其他情況,不過一般來說線程返回只有上面兩種了,跟協程一個道理 
    } 
} 
//執行結束,虛擬機退出 

在這裏,每一個Thread是一個自動機,在主循環中的事情非常簡單,找到一個當前可以執行的線程t,調用t.run(),在run中會從t的當前狀態(也就是上次run返回時的狀態)開始執行,直到線程結束或需要切換,run返回後,主循環判斷線程返回狀態,若線程是主動放棄(yield),則切換下一個t執行,若是線程結束,則處理善後工作 

若不考慮阻塞(比如所有線程都是計算密集型代碼,且互不相關),則只需要一個running隊列即可,每個線程執行一段時間,執行標準調度返回STAT_YIELD,切換到另一個,反覆如此直到所有線程都執行完畢,這是非常簡單的,或者我們可以乾脆不考慮標準調度,則各個線程是串行執行的,執行main_thread的run的時候創建的線程都只是加入thread_table和running隊列,在main執行結束後挨個進行。顯然,我們使用線程不是爲了串行計算 

回想下os對線程的調度方式,每個線程有自己的棧空間,棧的內容可以看做是它的主要狀態之一,在切換上下文的時候,保持當前棧不變,修改寄存器環境,直接jmp到另一個線程的指令處,和上篇說的協程用goto自由跳轉實現一樣 

在上面的虛擬機模型中,情況也是類似,使用t.run()來代替直接jmp,而每個線程的棧則保存在Thread對象內部,這意味着無論我們是否通過遞歸調用execute來實現函數調用,execute內部不能再保存任何和狀態相關的數據了(局部變量,指令索引,運算棧等),統統都需要放在Thread對象裏面,實際上execute裏只能有一些虛擬機內部使用的臨時變量。Thread對象中保存的線程棧可以用鏈表,用多少申請多少,動態增減,這樣就解決了前述os級線程地址空間佔用的問題,當然會造成一些性能損耗,但是可以通過用戶態調度彌補回來 

實現線程狀態和代碼分離,只需要將字節碼解釋的execute放在Thread類中,或將當前線程t作爲參數傳給execute。函數調用的棧幀用Frame類: 
class Frame 
{ 
    LocalVarTable local_var_table; //局部變量表 
    Stack stk; //當前函數運算棧 
    int idx; //執行到的指令 
}; 
Thread中則使用LIFO的數據結構存Frame層次關係,比如用vector(鏈表也行): 
Vector<Frame> frame_stk; 

從使用上來說,run執行的時候分兩種情況,第一次執行和繼續上次斷點執行,但是由於初始狀態也是一種斷點,因此就不做區分了,在run方法中只是簡單調用execute: 
void run() 
{ 
    this.stat = STAT_RUNNING; //先設置狀態 
    assert this.frame_stk.size() > 0; //這個assert失敗說明有bug 
    this.execute(0); //參數是frame_stk的索引 
    //execute正常退出時不會更改this.stat 
    if (this.stat == STAT_RUNNING) 
    { 
        //最外層execute正常執行完畢,獲取結果,清理狀態 
        assert this.frame_stk.size() == 1; 
        this.result = this.frame_stk[0].result; 
        this.frame_stk.pop(); 
        this.stat = STAT_FINISH; 
    } 
    else 
    { 
        //這裏this.stat == STAT_YIELD 
    } 
} 

考慮一下,一個線程yield的時候,斷點可能在一個比較深的函數調用,雖然frame_stk把數據和執行點保存下來了,但由於使用的宿主語言不支持直接jmp,而是先設置this.stat,然後逐層返回,最後直到run返回,那麼反過來,從斷點開始繼續執行時,要恢復宿主語言原先的調用棧,因此這個地方需要對字節碼CODE_CALL_FUNC做特殊處理: 
首先約定execute的傳入參數是current_frame_idx,然後: 
Frame current_frame = this.frame_stk[current_frame_idx]; 
接着是CODE_CALL_FUNC的實現: 
case CODE_CALL_FUNC: 
{ 
    if (this.frame_stk.size() > current_frame_idx + 1) 
    { 
        //當前棧幀並非最後棧幀,表示處於斷點恢復過程 
        //這裏啥都不用做 
    } 
    else 
    { 
        //正常執行到這個位置,非斷點恢復 
        //新建棧幀並將參數從當前運算棧拷貝參數到新棧幀,作爲局部變量 
        Frame next_frame = new Frame(current_frame.stk, inst.arg); 
        this.frame_stk.push_back(next_frame); //新棧幀壓棧 
    } 
    //無論是恢復還是正常調用,在這裏的狀態都一致了 
    this.execute(current_frame_idx + 1); //進入下一個調用棧 
    //調用結束或中斷,檢查 
    if (this.stat == STAT_RUNNING) 
    { 
        //正常結束 
        current_frame.stk.push(this.frame_stk[current_frame_idx + 1]); //返回值入運算棧 
        this.frame_stk.pop(); //清理棧幀 
        continue; //繼續運算當前函數調用 
    } 
    //走到這裏說明yield了 
    -- current_frame.idx; //根據前面幾篇代碼的約定,idx這時候指向下一條指令,恢復到當前指令 
    return; //直接返回 
}
最後還需要稍稍改一下CODE_RETURN: 
case CODE_RETURN: 
{ 
    //棧頂是需要返回的值 
    current_frame.result = stk.pop(); 
    return; 
} 

這段僞代碼如果正確實現,完成功能應該是沒問題的,不過我寫完了發現有個槽點,判斷是否處於斷點恢復狀態的代碼其實可以在execute執行一開頭就做了,這樣CODE_CALL_FUNC的代碼能更清晰些,而且不用恢復idx,不過這點懶得改了;但另一點,由於這裏的實現還是用宿主語言的execute遞歸來實現源語言的函數調用,因此每次yield和恢復都會有消耗(如果調用棧比較深),更好的做法我覺得是改進編譯器,將CODE_CALL_FUNC和CODE_RETURN都通過壓棧和CODE_JMP來實現(就像彙編那樣),這樣一來宿主語言的run只需要調用一層execute 

P.S.還有一個可能有改進空間的細節,這裏我們把運算棧放在棧幀中,而事實上如果虛擬機沒有bug,則所有調用可共用一個運算棧,手工模擬下就知道這是沒有問題的(還覺得不保險可以加個特殊的分界元素);另外,局部變量和運算棧也分開了,這個也是可以合併的 

這裏因爲不考慮異常機制等細節,execute返回只有兩種情況,finish和yield,其中finish時的處理上面寫得很清楚了,返回值存在當前棧幀後直接return,由調用者負責銷燬被調用者的棧幀,yield的情況則更簡單: 
this.stat = STAT_YIELD; 
return; 
只要當前棧幀的狀態沒有錯,下次就可以恢復了 

在這個框架下,標準調度很容易實現,就不囉嗦了
發佈了49 篇原創文章 · 獲贊 19 · 訪問量 8萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章