爲了結合兩者的優點,需要在語言實現上下功夫,具體地說,就是在寫源語言的時候,可以像很多流行的語言一樣創建線程,使用方式也一樣,但源語言的線程並不對應宿主環境的真線程,宿主環境可以自始至終單線程執行,在執行時對源語言的邏輯線程進行調度,具體實現跟協程和線程都有相似的地方,簡單說就是在虛擬機實現一個簡化版的小型操作系統,只不過這個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;
只要當前棧幀的狀態沒有錯,下次就可以恢復了 在這個框架下,標準調度很容易實現,就不囉嗦了