回複內容:
早期 linux 也是大核心鎖,進核心態就整個上鎖,使用者態可以並行。Big Kernel Lock 在語義上的好處是不存在死結,既有的非多線程代碼可以利用一點多核的優勢。
“更好的設計” 並不有趣,只能一點一點地一粒一粒地替換成細力度的鎖,這份工作在 linux 社區好像做了很多年。但是 linux 好在並不對 API 做承諾,誰修改了介面,誰就可以自己去修改所有的擴充,不在主線裡的擴充不 care,掛就掛,誰讓他們不進主線呢。
但 Python/Ruby 這種基於 C 的三方庫文化濃厚的鬆散社區,介面的改動成本太高了,何況效益並不一定顯著,比如:
- 確保細部鎖定之後,原本的單線程指令碼的效能沒有影響?
- 用三方庫死結了怎麼規範?
- 網路 IO 任務可以用協程緩一緩,那我們弄多線程圖啥?計算密集誰用 python?
python wiki 裡有篇文章比較詳細地列了為什麼幹不掉 GIL: GlobalInterpreterLock
如果是新語言,躲開 GIL 則相對容易,限制 C 擴充的介面只走 FFI,不暴露解譯器的內部實現細節,鎖粒度的粗細就只是實現層面的問題了。但這要求語言的效能足夠快,這時又成了一個效能和發布時間的權衡。GIL算不得低效的設計。其壞處在於鎖的粒度太粗。在我看來Lua的設計就很好。
Lua也有類似於GIL的結構,但鎖的範圍僅限於一個runtime環境。而一個OS進程內可以同時存在多個lua runtime環境。這使得一個lua環境裡同一時間只能有一個正在執行的語句。想要OS進程內的並存執行,可以同時開很多個Lua環境,他們之間可以做通訊。
所以Lua只鎖住一個Lua環境,OS進程裡可以有多個Lua環境一起執行。而Python GIL的問題在於他是OS進程裡全域的。導致了不能同時執行Python語句。
一個環境裡用鎖來同步使得虛擬機器的設計變的簡單和高效。這本身無可厚非。Java在早期也是如此,但後來Java花了很大的精力在環境裡做了更細粒度的鎖。使得Java可以在一個虛擬機器環境裡並存執行多個線程。這使得Java的虛擬機器變的複雜了許多,效能也會受到影響,因為代碼並不好預測哪些變數和資源需要放到鎖保護的地區裡,只能全都檢查一遍。而將虛擬機器環境做一個全域鎖就不需要檢查每一個變數和資源了。
所以我認為好的設計。就是將GIL鎖的範圍從OS進程的全域改為虛擬機器全域。使得一個OS進程裡可以同時存在多個虛擬機器。每個虛擬機器裡因為GIL的存在只能使用一個CPU核心。但多個虛擬機器使得整個OS進程可以利用多個CPU核心,同時因為在一個OS進程內,資料交換可以直接用引用傳遞,不涉及記憶體拷貝帶來的巨大開銷。加細粒度的lock實際上會讓CPython跑單線程的程式時變慢,樓上一個答案說的還不如加個大鎖就是這意思。。
大多數人覺得GIL不好,移除了多線程就能跑滿多核了,這個思考方式有問題。。。移除GIL會顯著增加CPython實現複雜度,拋開這個不提,寫共用記憶體多線程程式的難度依然不會下降,你在C裡會跌的坑,在這裡一個不會少。
所以順其自然吧,反正CPython水平也就那樣,就不是衝著效能去的。。Python想佔滿多核也簡單,做科學計算的話,用Theano/NumbaPro/numexpr最佳化numpy計算或者Cython裸寫多線程都行,做高並發伺服器的話,multiprocessing直接spawn出子進程來搞啊,而且像pulsar這種架構都幫你做好了細部鎖定 + STM + TLS 呀:
- 用細部鎖定來解決 GIL,如今的 mutex 使用 futex 在使用者態實現,比原來快很多了。
- 用 STM 來消減核心路徑細部鎖定的開銷
- 用 TLS 來規避 time.time, random.randint 之類的全域函數訪問同一個資源時發生的搶佔
工作量問題。
---------------
接 @fleuria Linux核心用了更細部鎖定的問題,為啥 Linux 用更細部鎖定不會影響效能,而 Python用細部鎖定在沒有競爭時又會導致額外開銷呢?
因為核心用細部鎖定是 spin_lock,即 cmpxchg + while,在多核之間的競爭開銷幾乎為零,而同核之間並不會出現 spin_lock的開銷,因為單核內開始 spin_lock 之前,一般都是把本cpu核心的 irq 給 disable掉的,見Linux irq_disable() / local_irq_disable() 等函數的實現,在允許 irq 前不會發生時鐘中斷導致任務切換。
而使用者態能不能用 spin_lock呢?幾乎不能,因為單核之間如果進入了 spin_lock 沒有 unlock時被時鐘中斷給切走了,那麼新調度進來的任務將會進入 spin_lock 的 while (cmpxchg(...) == xxx) ; 迴圈並且必須不停迴圈直到幾十毫秒後一個時鐘片結束被切回到第一個任務裡 spin_unlock了,第二個任務才能獲得鎖並結束迴圈。這就是因為使用者態無法把 irq 給 disable 掉,不能禁止時鐘中斷導致的任務被動切換後會進入上面的 spin_lock 單核內耗狀態。
所以使用者進程還得使用比核心 spin_lock + irq_disable/enable 組合慢很多倍的 mutex。索性 mutex有了使用者態實現的 futex ,能在 lock時用 cmpxchg 先檢測一下,如果沒有競就不爭必進入核心態了,而真正有競爭時才進入核心態,所以 mutex 的佔用比原來降低了不少。
所以如今有了 futex後,Python 在使用者態用更細部鎖定是有可能的,佔用比以前少了幾個數量級了。然而即便是 futex 的效能也達不到核心態的 spin_lock + irq_disable,任然存在一些微弱的開銷,那麼在核心路徑使用 stm 來解決,將會是一個比較好的搭配。
而過分暴露 C 介面給應用程式確實會帶來一些問題,會使得細部鎖定和 STM 的實現變得更為複雜,工作量非常大,這也是 GIL 存在的真正原因。-------- 2015-02-17 更新 --------
評論區開始變得有意思了。所以做下補充說明。
關於 ref count 和鎖。我舉的例子不恰當。
為了避免陷入具體語言實現細節。所以就以簡單實際模型來做討論。
結論是:ref count 來實現 gc 的話,不需要 os 提供的 thread lock 就可以。
gc, 重點是發現 g, 然後 c.
增加引用是不會出現 g 的。增加引用後,其 rec count 值必然 >= 2 (創造 sth 時 ref count 為 1).
ref count 變成 0 後,是不會再變成 1 的。要讓一個資源被人引用,首先資源必須存在才行。如果編譯器實現是沒問題的話,只有在語義上那個資源也是不可見時 ref count 才會變成 0。
interlocked operation 是什麼呢?其實就是一個執行期間保持 lock 訊號的,目地運算元為 mem 的一些非串指令(加 減 位元運算 位測試等),保證多處理器訪問共用記憶體時的獨佔問題。
這個指令就能避免這樣的情況: ref count 為 2 時,兩個不同處理器都對其 dec 後結果是 1(應該為0) 。
如何? GC?
if lockDecr(&sth.ref) then free(sth); /* 實際應用的話, free 前需遞迴地對所有被 sth 引用的其他資源進行「發現,回收」動作。 *//* if 發現 sth 為 G then C(sth) */
替代 GIL 的更好設計是 coroutine(協程)。既然同一時間只能有一個 thread 在運行,不如讓語言的虛擬機器自己操作 coroutine stack。GIL 是個偷懶的做法,用 C runtime stack 來實現語言的 stack,然後用作業系統的核心調度來類比 coroutine 自己操作 stack 的實現。
推薦一篇文章:Working simultaneously vs waiting simultaneously
注意其中說 GIL isn't great 的部分,提到了 Go 和 Erlang 的 context management。
其中還提到了 Coroutines in one page of C
,和 Lua coroutine 的實作類別似。
像 Ruby 有 continuation,就可以實現 coroutine 了。不過 full-continuation 的效率比 one-shot continuation (coroutine) 低很多。不知道 Ruby 有沒有實現後者。
實在沒有 coroutine,可以像 Node.js 那樣用回調實現(類似 CPS)。不過 Python 沒有 closure 就沒辦法了。
所以說,GIL 低效,但是還不算太糟糕。糟糕的是 Python 既用 GIL 還沒有 closure,Ruby 既有 GIL 還有 continuation,這些放在一起就不搭了。GIL不是設計,只是對早期沒考慮到的問題的fix。所以相反只要考慮到多線程就行了(這裡指實現層面,比如拿Ruby打比方jruby是可以支援真正多核的,但是Ruby因為標準庫和很多c擴充沒有考慮多線程的情況所以加上了GIL)。拋開GIL從語言層面上說的話加入類似Erlang的actor,rust的task之類特性都可以很輕鬆的寫出正確的多線程程式鎖是共用的,更小粒度的lock應當比global 好