Python多線程編程(一):GIL鎖

目錄

1.GIL鎖定義

2. 什麼是線程安全,什麼又是原子操作?

2.1 線程安全

2.2 原子操作

2.2.1 什麼是原子操作

2.2.2 如何分辨原子操作與非原子操作

3. GIL的優點與缺點

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

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

6.總結


主要參考以下幾篇文章:

https://blog.csdn.net/abc_12366/article/details/80231617?fps=1&locationNum=2

https://www.cnblogs.com/ArsenalfanInECNU/p/10022740.html

https://www.jianshu.com/p/fb81d5570f05

轉載自:https://www.cnblogs.com/ArsenalfanInECNU/p/9968621.html

1.GIL鎖定義

GIL的全稱是 Global Interpreter Lock,全局解釋器鎖。之所以叫這個名字,是因爲Python的執行依賴於解釋器。Python最初的設計理念在於,爲了解決多線程之間數據完整性和狀態同步的問題,設計爲在任意時刻只有一個線程在解釋器中運行。這是CPython解釋器的缺陷,由於CPython是大部分環境下默認的Python執行環境,而很多庫都是基於CPython編寫的,因此很多人將GIL歸結爲Python的問題。當執行多線程程序時,由GIL來控制同一時刻只有一個線程能夠運行。即Python中的多線程是表面多線程,也可以理解爲fake多線程,不是真正的多線程。

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

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

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

那麼問題來了,Python爲什麼要如此設計呢?即爲什麼要保證同一時刻只有一個線程在解釋器中運行呢

答案是爲了Python解釋器中原子操作的線程安全

 

2. 什麼是線程安全,什麼又是原子操作?

2.1 線程安全

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

舉一個例子,如果我們把跑程序比作喫飯,那麼進程就是擺滿了飯菜的桌子,線程就是喫飯的那個人。

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

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

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

2.2 原子操作

2.2.1 什麼是原子操作

原子操作就是不會因爲進程併發或者線程併發而導致被中斷的操作原子操作的特點就是要麼一次全部執行,要麼全不執行。不存在執行了一半而被中斷的情況。

當對全局資源存在寫操作時,如果不能保證寫入過程的原子性,會出現髒讀髒寫的情況。

非原子操作示例:

import threading

count = 0

def run_thread():
    global count
    for i in range(10000):
        count += 1

t1 = threading.Thread(target=run_thread,args=())
t2 = threading.Thread(target=run_thread,args=())
t1.start()
t2.start()
t1.join()
t2.join()

print(count)

如果運行上面的代碼,打印出的count的結果是不確定的,它會小於20000.但count實際是被增加了20000次。

爲什麼這樣呢?其實就是這裏的寫入操作count += 1並不是原子的。它實際經過了三步:一個線程每運行 1000 字節碼,就會被解釋器打斷奪走 GIL 。

1. 讀入count變量指向的值; 
2. +1
3. 讓count變量指向新的結果值。

由於一個線程每運行 1000 字節碼,就會被解釋器打斷奪走 GIL 。而run_thread的一個線程使用了10000*3個字節碼,所以在運行的過程中就會被打斷,導致加法運行失敗,於是最後的結果會小於20000。

 

 

2.2.2 如何分辨原子操作與非原子操作

比如對下面這個函數:

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被設計來保護線程安全,由於多線程共享變量,如果不能很好的進行線程同步,多線程非常容易將線程改亂。實際上即使有了GIL,這個問題也無法完全解決,因爲GIL實際上也會釋放,而且它並不是在某個線程執行完成後才釋放,而是根據代碼的字節碼或者時間片進行釋放。

所以,儘管有 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 類,該類相當於互斥鎖。

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

 

6.總結

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

  • 因爲GIL的存在,只有IO Bound場景下得多線程會得到較好的性能
  • 如果對並行計算性能較高的程序可以考慮把核心部分也成C模塊,或者索性用其他語言實現
  • 在Python編程中,如果想利用計算機的多核提高程序執行效率,用多進程代替多線程
  • 即使有GIL存在,由於GIL只保護Python解釋器的狀態,所以對於非原子操作,在Python進行多線程編程時也需要使用互斥鎖(如thread中的lock)保證線程安全。
  • GIL在較長一段時間內將會繼續存在,但是會不斷對其進行改進
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章