1. 程式人生 > >聊聊Python中的GIL python中的GIL詳解

聊聊Python中的GIL python中的GIL詳解

對於廣大寫Python的人來說,GIL(Global Interpreter Lock, 全域性直譯器鎖)肯定不陌生,但未必清楚GIL的歷史和全貌是怎樣的,今天我們就來梳理一下GIL。

1. 什麼是GIL

GIL的全稱是 Global Interpreter Lock,全域性直譯器鎖。之所以叫這個名字,是因為Python的執行依賴於直譯器。Python最初的設計理念在於,為了解決多執行緒之間資料完整性和狀態同步的問題,設計為在任意時刻只有一個執行緒在直譯器中執行。而當執行多執行緒程式時,由GIL來控制同一時刻只有一個執行緒能夠執行。即Python中的多執行緒是表面多執行緒,也可以理解為fake多執行緒,不是真正的多執行緒。

可能有的同學會問,同一時刻只有一個執行緒能夠執行,那麼是怎麼執行多執行緒程式的呢?其實原理很簡單:直譯器的分時複用。即多個執行緒的程式碼,輪流被直譯器執行,只不過切換的很頻繁很快,給人一種多執行緒“同時”在執行的錯覺。聊的學術化一點,其實就是“併發”。

再拓展一點“併發”和“並行”的概念:

普通解釋:
併發:交替做不同事情的能力
並行:同時做不同事情的能力
專業術語:
併發:不同的程式碼塊交替執行
並行:不同的程式碼塊同時執行

那麼問題來了,Python為什麼要如此設計呢?即為什麼要保證同一時刻只有一個執行緒在直譯器中執行呢

答案是為了程序安全

 

2. 什麼是執行緒安全?

我們首先要搞清楚什麼是程序,什麼是執行緒。程序是系統資源分配的最小單位,執行緒是程式執行的最小單位

舉一個例子,如果我們把跑程式比作吃飯,那麼程序就是擺滿了飯菜的桌子,執行緒就是吃飯的那個人。

在多執行緒環境中,當各執行緒不共享資料的時候,那麼一定是執行緒安全的。問題是這種情況並不多見,在多數情況下需要共享資料,這時就需要進行適當的同步控制了。

執行緒安全一般都涉及到synchronized,就是多執行緒環境中,共享資料同一時間只能有一個執行緒來操作 不然中間過程可能會產生不可預製的結果

接著剛才的例子,桌子上有三碗米飯,一個人正在吃,吃了兩碗米飯,但是還沒有吃完,因此桌子上米飯的數量還沒有更新;此時第二個人也想吃米飯,如果沒有執行緒安全方面的考慮,第二個人要是想直接拿三碗米飯吃,就會出錯。

以下是這種情況的程式碼示例:

n = 0
 
def foo():
    global n
    n += 1

我們可以看到這個函式用 Python 的標準 dis 模組編譯的位元組碼:

>>> import dis
>>> dis.dis(foo)
LOAD_GLOBAL              0 (n)
LOAD_CONST               1 (1)
INPLACE_ADD
STORE_GLOBAL             0 (n)

程式碼的一行中, n += 1,被編譯成 4 個位元組碼,進行 4 個基本操作:

  1. 將 n 值載入到堆疊上
  2. 將常數 1 載入到堆疊上
  3. 將堆疊頂部的兩個值相加
  4. 將總和儲存回 n
記住,一個執行緒每執行 1000 位元組碼,就會被直譯器打斷奪走 GIL 。如果運氣不好,這(打斷)可能發生線上程載入 n 值到堆疊期間,以及把它儲存回 n 期間。很容易可以看到這個過程會如何導致更新丟失:
threads = []
for i in range(100):
    t = threading.Thread(target=foo)
    threads.append(t)
 
for t in threads:
    t.start()
 
for t in threads:
    t.join()
 
print(n)
通常這個程式碼輸出 100,因為 100 個執行緒每個都遞增 n 。但有時你會看到 99 或 98 ,如果一個執行緒的更新被另一個覆蓋。   所以,儘管有 GIL,你仍然需要加鎖來保護共享的可變狀態:
n = 0
lock = threading.Lock()
 
def foo():
    global n
    with lock:
        n += 1

 

3. GIL的優點與缺點

GIL的優點是顯而易見的,GIL可以保證我們在多執行緒程式設計時,無需考慮多執行緒之間資料完整性和狀態同步的問題

GIL缺點是:我們的多執行緒程式執行起來是“併發”,而不是“並行”。因此執行效率會很低,會不如單執行緒的執行效率。

網上很多人都提到過這樣的疑問:”為什麼我多執行緒Python程式執行得比其只有一個執行緒的時候還要慢?“顯然,大家覺得一個具有兩個執行緒的程式要比其只有一個執行緒時要快。事實上,這個問題是確實存在的,原因在於GIL的存在使得Python多執行緒程式的執行效率甚至比不上單執行緒的執行效率。很簡單,由於GIL使得同一時刻只有一個執行緒在執行程式,再加上切換執行緒和競爭GIL帶來的開銷,顯然Python多執行緒的執行效率就比不上單執行緒的執行效率了。

 

4. 為什麼會有GIL,GIL的歷史

大家顯然會繼續思考,為什麼GIL需要保證只有一個執行緒在某一時刻處於執行中?難道不可以新增細粒度的鎖來阻止多個獨立物件的同時訪問?並且為什麼之前沒有人去嘗試過類似的事情?

這些實用的問題有著十分有趣的回答。首先要明確一點, Python直譯器的實現是有多個版本的:CPython, Jpython等。CPython就是用C語言實現Python直譯器,JPython是用Java實現Python直譯器。那麼 GIL的問題實際上是存在於CPython中的。GIL的問題得不到解決,一方面是因為CPython中一開始就使用GIL的設計理念,並且很多Package依賴於CPython甚至依賴於GIL。因此造成尾大不掉,實際上是個歷史問題。

為了利用多核,Python開始支援多執行緒。而解決多執行緒之間資料完整性和狀態同步的最簡單方法自然就是加鎖。 於是有了GIL這把超級大鎖,而當越來越多的程式碼庫開發者接受了這種設定後,他們開始大量依賴這種特性(即預設python內部物件是thread-safe的,無需在實現時考慮額外的記憶體鎖和同步操作)。

慢慢的這種實現方式被發現是蛋疼且低效的。但當大家試圖去拆分和去除GIL的時候,發現大量庫程式碼開發者已經重度依賴GIL而非常難以去除了。有多難?做個類比,像MySQL這樣的“小專案”為了把Buffer Pool Mutex這把大鎖拆分成各個小鎖也花了從5.5到5.6再到5.7多個大版為期近5年的時間,本且仍在繼續。MySQL這個背後有公司支援且有固定開發團隊的產品走的如此艱難,那又更何況Python這樣核心開發和程式碼貢獻者高度社群化的團隊呢?

GIL對諸如當前執行緒狀態和為垃圾回收而用的堆分配物件這樣的東西的訪問提供著保護。這是該實現的一種典型產物。現在也有其它的Python直譯器(和編譯器)並不使用GIL。雖然,對於CPython來說,自其出現以來已經有很多不使用GIL的直譯器。

那麼為什麼不拋棄GIL呢?許多人也許不知道,在1999年,針對Python 1.5,一個經常被提到但卻不怎麼理解的“free threading”補丁已經嘗試實現了這個想法,該補丁來自Greg Stein。在這個補丁中,GIL被完全的移除,且用細粒度的鎖來代替。然而,GIL的移除給單執行緒程式的執行速度帶來了一定的代價。當用單執行緒執行時,速度大約降低了40%。使用兩個執行緒展示出了在速度上的提高,但除了這個提高,這個收益並沒有隨著核數的增加而線性增長。由於執行速度的降低,這一補丁被拒絕了,並且幾乎被人遺忘。

不過,“free threading”這個補丁是有啟發性意義的,其證明了一個關於Python直譯器的基本要點:移除GIL是非常困難的。由於該補丁釋出時所處的年代,直譯器變得依賴更多的全域性狀態,這使得想要移除當今的GIL變得更加困難。值得一提的是,也正是因為這個原因,許多人對於嘗試移除GIL變得更加有興趣。困難的問題往往很有趣。

但是這可能有點被誤導了。讓我們考慮一下:如果我們有了一個神奇的補丁,其移除了GIL,並且沒有對單執行緒的Python程式碼產生效能上的下降,那麼我們將會獲得我們一直想要的:一個執行緒API可能會同時利用所有的處理器。但這確實是一個好事嗎?

基於執行緒的程式設計毫無疑問是困難的。在編碼過程中,總是會悄無聲息的出現一些新的問題。因此有一些非常知名的語言設計者和研究者已經總結得出了一些執行緒模型。就像某個寫過多執行緒應用的人可以告訴你的一樣,不管是多執行緒應用的開發還是除錯都會比單執行緒的應用難上數倍。程式設計師通常所具有的順序執行的思維模恰恰就是與並行執行模式不相匹配。GIL的出現無意中幫助了開發者免於陷入困境。在使用多執行緒時仍然需要同步的情況下,GIL事實上幫助我們保持不同執行緒之間的資料一致性問題。

所以簡單的說GIL的存在更多的是歷史原因。如果推到重來,多執行緒的問題依然還是要面對,但是至少會比目前GIL這種方式會更優雅。

 

5. 如何規避GIL帶來的影響

用multiprocess(多程序)替代Thread(推薦)

multiprocess庫的出現很大程度上是為了彌補thread庫因為GIL而低效的缺陷。它完整的複製了一套thread所提供的介面方便遷移。唯一的不同就是它使用了多程序而不是多執行緒。每個程序有自己的獨立的GIL,因此也不會出現程序之間的GIL爭搶

當然multiprocess也不是萬能良藥。它的引入會增加程式實現時執行緒間資料通訊和同步的困難。就拿計數器來舉例子,如果我們要多個執行緒累加同一個變數,對於thread來說,申明一個global變數,用thread.Lock的context包裹住三行就搞定了。而multiprocess由於程序之間無法看到對方的資料,只能通過在主執行緒申明一個Queue,put再get或者用share memory的方法。這個額外的實現成本使得本來就非常痛苦的多執行緒程式編碼,變得更加痛苦了。

用其他解析器(不推薦)

之前也提到了既然GIL只是CPython的產物,那麼其他解析器是不是更好呢?沒錯,像JPython和IronPython這樣的解析器由於實現語言的特性,他們不需要GIL的幫助。然而由於用了Java/C#用於解析器實現,他們也失去了利用社群眾多C語言模組有用特性的機會。所以這些解析器也因此一直都比較小眾。畢竟功能和效能大家在初期都會選擇前者,Done is better than perfect。

GIL與互斥鎖

值得注意的是GIL 並不會保護開發者自己編寫的程式碼。這是因為同一時刻固然只能有一個 Python 執行緒得到執行,但是,當這個執行緒正在操作某個資料結構的時候,其他執行緒可能會打斷它,一旦發生這種現象,就會破壞程式的狀態,從而使相關的資料結構無法保持其一致性。為了保證所有執行緒能夠得到公平地執行,Python 直譯器會給每個執行緒分配大致相等的處理器時間。為了達到這樣的分配策略,Python 系統可能當某個執行緒正在執行的時候將其暫停,然後使另一個執行緒繼續往下執行。由於我們無法提前獲知 Python 系統會在何時暫停這些執行緒,所以我們無法控制程式中某些操作是原子操作。

為了防止執行緒中出現數據競爭的行為,使開發者可以保護自己的資料結構不受破壞,Python 在 threading 模組中提供了最簡單、最有用的工具:Lock 類,該類相當於互斥鎖。

在開發中我們可以使用互斥鎖來保護某個物件,使得在多執行緒同時訪問某個物件的時候,不會將該物件破壞。因為同一時刻,只有一個執行緒能夠獲得這把鎖。也就是說對將要訪問的物件進行隔離,那麼使用執行緒隔離的意義在於:是當前執行緒能夠正確的引用到它自己創造的物件,而不是引用到其它執行緒鎖建立的物件。

 

總結

Python GIL其實是功能和效能之間權衡後的產物,它尤其存在的合理性,也有較難改變的客觀因素。我們可以做以下一些簡單的總結:

  • 因為GIL的存在,只有IO Bound場景下得多執行緒會得到較好的效能
  • 如果對平行計算效能較高的程式可以考慮把核心部分也成C模組,或者索性用其他語言實現
  • 在Python程式設計中,如果想利用計算機的多核提高程式執行效率,用多程序代替多執行緒
  • 即使有GIL存在,在Python進行多執行緒程式設計時也需要使用互斥鎖(如thread中的lock)保證執行緒安全。
  • GIL在較長一段時間內將會繼續存在,但是會不斷對其進行改進

 

參考連結:

1. 什麼是執行緒安全和執行緒不安全 https://blog.csdn.net/zjy_android_blog/article/details/69817476

2.  Python GIL https://www.aliyun.com/jiaocheng/446166.html

3.  python之理解GIL https://www.jianshu.com/p/573aaa001b35

4.  python面試不得不知道的點——GIL https://blog.csdn.net/weixin_41594007/article/details/79485847

5.  詳解Python GIL https://blog.csdn.net/liangkaiping0525/article/details/79490323

6.  深入理解python多執行緒與GIL https://blog.csdn.net/ybdesire/article/details/77842438

7. python中的GIL詳解 https://www.cnblogs.com/SuKiWX/p/8804974.html

8.  深入理解 GIL:如何寫出高效能及執行緒安全的 Python 程式碼 http://python.jobbole.com/87743/

9.  談談有關 Python 的GIL 和 互斥鎖 https://blog.csdn.net/Amberdreams/article/details/81274217