你是否真的了解全局解析鎖(GIL)

  • 2019 年 10 月 3 日
  • 筆記

關於我
一個有思想的程式猿,終身學習實踐者,目前在一個創業團隊任team lead,技術棧涉及Android、Python、Java和Go,這個也是我們團隊的主要技術棧。
Github:https://github.com/hylinux1024
微信公眾號:終身開發者(angrycode)

0x00 什麼是全局解析鎖(GIL)

A global interpreter lock (GIL) is a mechanism used in computer-language interpreters to synchronize the execution of threads so that only one native thread can execute at a time. —引用自wikipedia

從上面的定義可以看出,GIL是電腦語言解析器用於同步執行緒執行的一種同步鎖機制。很多程式語言都有GIL,例如PythonRuby

0x01 為什麼會有GIL

Python作為一種面向對象的動態類型程式語言,開發者編寫的程式碼是通過解析器順序解析執行的。
大多數人目前使用的Python解析器是CPython提供的,而CPython的解析器是使用引用計數來進行記憶體管理,為了對多執行緒安全的支援,引用了global intepreter lock,只有獲取到GIL的執行緒才能執行。如果沒有這個鎖,在多執行緒編碼中即使是簡單的操作也會引起共享變數被多個執行緒同時修改的問題。例如有兩個執行緒同時對同一個對象進行引用時,這兩個執行緒都會將變數的引用計數從0增加為1,明顯這是不正確的。
可以通過sys模組獲取一個變數的引用計數

>>> import sys  >>> a = []  >>> sys.getrefcount(a)  2  >>> b = a  >>> sys.getrefcount(a)  3

sys.getrefcount()方法中的參數對a的引用也會引起計數的增加。

是否可以對每個變數都分別使用鎖來同步呢?

如果有多個鎖的話,執行緒同步時就容易出現死鎖,而且編程的複雜度也會上升。當全局只有一個鎖時,所有執行緒都在競爭一把鎖,就不會出現相互等待對方鎖的情況,編碼的實現也更簡單。此外只有一把鎖時對單執行緒的影響其實並不是很大。

0x02 可以移除GIL嗎?

Python核心開發團隊以及Python社區的技術專家對移除GIL也做過多次嘗試,然而最後都沒有令各方滿意的方案。

記憶體管理技術除了引用計數外,一些程式語言為了避免引用全局解析鎖,記憶體管理就使用垃圾回收機制。

當然這也意味著這些使用垃圾回收機制的語言就必須提升其它方面的性能(例如JIT編譯),來彌補單執行緒程式的執行性能的損失。
對於Python的來說,選擇了引用計數作為記憶體管理。一方面保證了單執行緒程式執行的性能,另一方面GIL使得編碼也更容易實現。
Python中很多特性是通過C庫來實現的,而在C庫中要保證執行緒安全的話也是依賴於GIL

所以當有人成功移除了GIL之後,Python的程式並沒有變得更快,因為大多數人使用的都是單執行緒場景。

0x03 對多執行緒程式的影響

首先來GILIO密集型程式和CPU密集型程式的的區別。
像文件讀寫、網路請求、資料庫訪問等操作都是IO密集型的,它們的特點需要等待IO操作的時間,然後才進行下一步操作;而像數學計算、圖片處理、矩陣運算等操作則是CPU密集型的,它們的特點是需要大量CPU算力來支援

對於IO密集型操作,當前擁有鎖的執行緒會先釋放鎖,然後執行IO操作,最後再獲取鎖。執行緒在釋放鎖時會把當前執行緒狀態存在一個全局變數PThreadState的數據結構中,當執行緒獲取到鎖之後恢復之前的執行緒狀態

用文字描述執行流程

保存當前執行緒的狀態到一個全局變數中  釋放GIL  ... 執行IO操作 ...  獲取GIL  從全局變數中恢復之前的執行緒狀態

下面這段程式碼是測試單執行緒執行500萬次消耗的時間

import time    COUNT = 50000000    def countdown(n):      while n > 0:          n -= 1    start = time.time()  countdown(COUNT)  end = time.time()    print('Time taken in seconds -', end - start)    # 執行結果  # Time taken in seconds - 2.44541597366333

在我的8核的macbook上跑大約是2.4秒,然後再看一個多執行緒版本

import time  from threading import Thread    COUNT = 50000000    def countdown(n):      while n > 0:          n -= 1    t1 = Thread(target=countdown, args=(COUNT // 2,))  t2 = Thread(target=countdown, args=(COUNT // 2,))    start = time.time()  t1.start()  t2.start()  t1.join()  t2.join()  end = time.time()    print('Time taken in seconds -', end - start)    # 執行結果  # Time taken in seconds - 2.4634649753570557

上文程式碼每個執行緒都執行250萬次,如果執行緒是並發的,執行時間應該是上面單執行緒版本的一半時間左右,然而在我電腦中執行時間大約為2.5秒!
多執行緒不但沒有更高效率,反而還更耗時了。這個例子就說明Python中的執行緒是順序執行的,只有獲取到鎖的執行緒可以獲取解析器的執行時間。多執行緒執行多出來的那點時間就是獲取鎖和釋放鎖消耗的時間。

那如何實現高並發呢?

答案是使用多進程。前面的文章有介紹多進程的使用

from multiprocessing import Pool  import time    COUNT = 50000000    def countdown(n):      while n > 0:          n -= 1    if __name__ == '__main__':      pool = Pool(processes=2)      start = time.time()      r1 = pool.apply_async(countdown, [COUNT // 2])      r2 = pool.apply_async(countdown, [COUNT // 2])      pool.close()      pool.join()      end = time.time()      print('Time taken in seconds -', end - start)    # 執行結果  # Time taken in seconds - 1.2389559745788574

使用多進程,每個進程運行250萬次,大約消耗1.2秒的時間。差不多是上面執行緒版本的一半時間。

當然還可以使用其它Python解析器,例如JythonIronPythonPyPy

既然每個執行緒執行前都要獲取鎖,那麼有一個執行緒獲取到鎖一直佔用不釋放,怎麼辦?

IO密集型的程式會主動釋放鎖,但對於CPU密集型的程式或IO密集型和CPU混合的程式,解析器將會如何工作呢?
早期的做法是Python會執行100條指令後就強制執行緒釋放GIL讓其它執行緒有可執行的機會。
可以通過以下獲取到這個配置

>>> import sys  >>> sys.getcheckinterval()  100

在我的電腦中還列印了下面的輸出警告

Warning (from warnings module):    File "__main__", line 1  DeprecationWarning: sys.getcheckinterval() and sys.setcheckinterval() are deprecated.  Use sys.getswitchinterval() instead.

意思是sys.getcheckinterval()方法已經廢棄,應該使用sys.getswitchinterval()方法。
因為傳統的實現中每解析100指令的就強制執行緒釋放鎖的做法,會導致CPU密集型的執行緒會一直佔用GILIO密集型的執行緒會一直得不到解析的問題。於是新的執行緒切換方案就被提出來了

>>> sys.getswitchinterval()  0.005

這個方法返回0.05秒,意思是每個執行緒執行0.05秒後就釋放GIL,用於執行緒的切換。

0x04 總結

CPython解析器的實現由於global interpreter lock(全局解釋鎖)的存在,任何時刻都只有一個執行緒能執行Pythonbytecode(位元組碼)。
常見的記憶體管理方案有引用計數和垃圾回收,Python選擇了前者,這保證了單執行緒的執行效率,同時對編碼實現也更加簡單。想要移除GIL是不容易的,即使成功將GIL去除,對Python的來說是犧牲了單執行緒的執行效率。
PythonGILIO密集型程式可以較好的支援多執行緒並發,然而對CPU密集型程式來說就要使用多進程或使用其它不使用GIL的解析器。
目前最新的解析器實現中執行緒每執行0.05秒就會強制釋放GIL,進行執行緒的切換。

0x05 為了看懂GIL我閱讀了下面這些資料