GIL併發控制

如果一個語言要實現支持併發執行的接口,則一般來說需要在併發控制上下功夫,原因就是前面說的,由於虛擬機實現的細節問題,直接依賴宿主環境的併發容易出問題。簡單地,以使用宿主的線程爲例。假如源語言的線程對應宿主環境的真線程,那麼同步操作就需要用到線程間的互斥量,比如鎖,信號量等 

一個程序需要併發,一般來說有三個原因: 
一,爲充分利用多核cpu資源,提高計算速度。這個原因是很重要,但在實際中其重要性我覺得被過分誇大了,因爲首先需要將問題合理分配使得可以並行,其次應用場景是計算密集型,符合這兩個條件的場景不算很多,再者,如果問題已經合理分配了,那簡單地多開幾個進程也能解決。這個不討論 
二,實現感覺上的並行執行,這在UI程序中比較重要,主要是考慮使用者的體驗流暢,比如當一個客戶端程序突然有一個耗時比較長的計算任務時,不至於將界面卡住 
三,充分利用硬件資源,當一個線程阻塞在IO(或其他)操作時,cpu資源能給其他線程使用,這在後臺服務器程序中比較常見 

後面兩條剛好對應線程的兩種調度:標準調度(分時)和阻塞調度 
標準調度的時候,一個線程執行一段時間(或其他計數法)後,調度到另一個線程 
阻塞調度的時候,一個線程如果進入一個阻塞調用,調度到另一個線程 

虛擬機中實現調度控制,跟操作系統差不多,區別在於,分時調度的時候,操作系統一般是使用每隔一定時間產生的時鐘中斷,而虛擬機可以做成線程自己計數,差不多了就主動切換到另一個線程,這個跟阻塞調用的主動切換是一樣的。具體的切換策略以GIL爲例,這也是容易實現且效果也比好的一種方式,python和ruby的標準實現都在用 

GIL全稱全局解釋器鎖,就是一個互斥鎖,在虛擬機中,每個源語言的線程對應一條真線程,邏輯上只有當一個線程持有GIL的時候,才能執行。這裏說的邏輯上,是指虛擬機字節碼層面,且不包括阻塞,比如: 
release_gil(); 
... //只要這段代碼和環境沒有衝突即可,比如局部變量計算 
acquire_gil(); 
因此在python中,使用C擴展就可以繞過GIL,實現佔多核,但是併發執行的代碼決不能影響python虛擬機的環境,數據上要分離 

python中當一個線程正在執行時,其他線程或者在做阻塞調用,或者處於就緒狀態在acquire_gil(),因此當需要調度的時候,簡單地release_gil()即可 

標準調度可以按時間來,比如每個字節碼解釋循環後取一次時間,但是取時間在系統中一般是一個比較耗時的工作,可以使用另一種做法,根據字節碼來計數,每執行一定量字節碼後進行調度: 
extern int tick_count = 0; //全局的 
//下面是局部的,execute函數內部 
int idx = 0; 
for (;;) 
{ 
    ++ tick_count; 
    if (tick_count > MAX_TICK) 
    { 
        //標準調度 
        tick_count = 0; 
        release_gil(); 
        acquire_gil(); 
    } 
    Inst inst = inst_list[idx]; 
    ++ idx; 
    switch(inst.code) 
    { 
        ... 
    } 
} 

虛擬機維持一個全局的tick_count,表示累計的連續執行的字節碼數量,當連續執行超過MAX_TICK條指令時,就執行標準調度,重置tick_count並釋放gil。MAX_TICK的值一般可根據具體情況選擇,太大了實時性體驗不好,太小了由於頻繁加解鎖又對性能有影響,需要一個平衡 

有兩點需要注意: 
一,tick_count不能是局部變量,是由於執行CALL_FUNC指令時會遞歸調用execute 
二,tick_count的更新並不精確,會受一些其他因素影響,比如switch內部的指令預測跳轉,這時候不會更新tick_count,這實際上把帶預測的關聯指令變成了一個原子操作,如果預測鏈比較長,就會出現某個線程執行很久的情況。當然也可以在switch代碼裏面合適的地方對tick_count做操作和判斷,但一般來說沒必要,因爲指令預測之類的影響會控制在一定範圍 

阻塞調度就更簡單了,當虛擬機需要執行一個可能阻塞的地方的時候,釋放GIL,執行完成後在加鎖: 
void sleep(Object[] arg) 
{ 
    //先檢查參數 
    if (arg.length != 1) ...; 
    if (!(arg[0] instanceof IntObj)) ...; 
    int sec = ((IntObj)arg[0]).value; 
    release_gil(); 
    sys_sleep(sec); //調用系統的sleep 
    acquire_gil(); 
} 

阻塞調度的時候,可以選擇更新或不更新tick_count,都沒有什麼關係,因爲不更新只可能讓切換更頻繁,不影響實時性體驗 

假如對實時性體驗要求不高,可以將MAX_TICK設爲正無窮,相當於去掉標準調度,將搶佔式調度修改爲非搶佔調度,省去了大量加解鎖的操作,整體性能會有提升。但是直接使用操作系統的線程還是有一些實現相關的問題: 
一、一般os中每個線程都有自己的棧,雖然物理內存是實際使用時按頁映射的,但消耗了進程地址空間,而線程棧一旦溢出進程就崩潰了,所以一般都搞大點,在32bit下這就給線程數量造成限制(32bit還有低端內存的問題) 
二、線程調度要進入內核態,而這時一個非常耗資源的過程,線程太多的話會導致大量cpu時間浪費在調度計算上,雖然大部分時候我們只需要很簡單的調度策略,但操作系統爲了通用性,下層的算法還是比較複雜的。而且os還要處理其他關於線程的工作,可以在win下面調小線程棧,開上幾萬個線程,看看有沒有卡死 

因爲這兩點原因,在後臺服務器處理高併發請求時一般不會一個請求或一個用戶就給開一個線程,而是用線程池或單線程異步的方式來做,但如果阻塞時間較久(比如網絡延時),線程池也不能實現充分利用資源,而單線程異步代碼寫起來又比較反人類直覺,於是就出現了結合兩者的併發編程實現,接口、使用和線程一致,底層轉爲單線程異步實現,不陷入內核態,在用戶層調度的線程
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章