《深度剖析CPython解釋器》25. 解密Python中的多線程(第一部分):初識GIL、以及多個線程之間的調度機制


楔子

這次我們來說一下Python中的多線程,在上篇博客中我們說了Python的線程,我們說Python中的線程是對OS線程進行了一個封裝,並提供了一個線程狀態(PyThreadState)對象,來記錄OS線程的一些狀態信息。

那什么是多線程呢?首先線程是操作系統調度cpu工作的最小單元,同理進程則是操作系統資源分配的最小單元,線程是需要依賴於進程的,並且每一個進程只少有一個線程,這個線程我們稱之為主線程。而主線程則可以創建子線程,一個進程中如果有多個線程去工作,我們就稱之為多線程。

開發一個多線程應用程序是很常見的事情,很多語言都支持多線程,有的是原生支持,有的是通過庫的支持。而Python毫無疑問也支持多線程,並且它是通過threading這個庫的方式實現的。另外提到Python的多線程,會讓人想到GIL(global interpreter lock)這個萬惡之源,我們后面會詳細介紹。目前我們知道Python中的多線程是不能利用多核的,因為Python虛擬機使用一個全局解釋器鎖(GIL)來控制線程對程序的執行,這個結果就使得無論你的cpu有多少核,但是同時被線程調度的cpu只有一個。不過底層是怎么做的呢?我們下面就來分析一下。

GIL與線程調度

首先我們來分析一下為什么會有GIL這個東西存在?看兩行代碼:

import dis

dis.dis("del name")
"""
  1           0 DELETE_NAME              0 (name)
              2 LOAD_CONST               0 (None)
              4 RETURN_VALUE

"""

當我們使用del刪除一個變量的時候,對應的指令是DELETE_NAME,這個指令對應的源碼中可以自己去查看。總之這條指令做的事情就是通過宏 Py_DECREF 減少一個對象的引用計數,並且判斷減少之后其引用計數是否為0,如果為0就進行回收。偽代碼如下:

--obj->ob_refcnt
if (obj -> ob_refcnt == 0){
	銷毀obj
}

所以總共是兩步:第一步先將對象的引用計數減1;第二步判斷引用計數是否為0,為0則進行銷毀。那么問題來了,假設有兩個線程A和B,內部都引用了全局變量obj,此時obj指向的對象的引用計數為2,然后讓兩個線程都執行del obj這行代碼。

其中A線程先執行,如果A線程在執行完--obj->ob_refcnt之后,會將對象的引用計數減一,但不幸的是這個時候調度機制將A掛起了,喚醒了B。而B也執行del obj,但是它比較幸運,將兩步都一塊執行完了。而由於之前A已經將引用計數減1,所以再減1之后會發現對象的引用計數為0,從而執行了對象的銷毀動作,內存被釋放。

然后A又被喚醒了,此時開始執行第二個步驟,但由於obj->ob_refcnt已經被減少到0,所以條件滿足,那么A依舊會對obj指向的對象進行釋放,但是這個對象所占內存已經被釋放了,所以obj此時就成了懸空指針。如果再對obj指向的對象進行釋放,最終會引發什么結果,只有天知道。

關鍵來了,所以Python引入了GIL,GIL是解釋器層面上的一把超級大鎖,它是字節碼級別的互斥鎖,作用就是:在同時一刻,只讓一個線程執行字節碼,並且保證每一條字節碼在執行的時候都不會被打斷。

所以由於GIL的存在,會使得線程只有把當前的某條字節碼指令執行完畢之后才有可能會發生調度。因此無論是A還是B,線程調度時,要么發生在DELETE_NAME這條指令執行之前,要么發生在DELETE_NAME這條指令執行完畢之后,但是不存在指令(不僅是DELETE_NAME, 而是所有指令)執行到一半的時候發生調度。

因此GIL才被稱之為是字節碼級別的互斥鎖,它是保護字節碼指令只有在執行完畢之后才會發生線程調度。所以回到上面那個del obj這個例子中來,由於引入了GIL,所以就不存在我們之前說的:在A將引用計數減一之后,掛起A、喚醒B這一過程。因為A已經開始了DELETE_NAME這條指令的執行,所以在沒執行完之前是不會發生線程調度的,至於為什么我們后面通過源碼分析就知道了,總之此時就不會發生懸空指針的問題了。

事實上,GIL在單核時代,其最初的目的就是為了解決引用計數的安全性問題,只不過Python的作者龜叔沒想到多核會發展的這么快。

那么,GIL是否會被移除呢?因為對於現在的多核cpu來說,Python的GIL無疑是進行了限制。

關於能否移除GIL,從個人的角度來說不太可能,這都幾十年了,能移除早就移除了。

而且事實上,在Python誕生沒多久,就有人發現了這一詭異之處,因為當時的程序猿發現使用多線程在計算上居然沒有任何性能上的提升,反而還比單線程慢了一點。當時Python的官方人員直接回復:不要使用多線程,而是使用多進程。

此時站在上帝視角的我們知道,因為GIL的存在使得同一時刻只有一個核被使用,所以對於純計算的代碼來說,理論上多線程和單線程是沒有區別的。但是由於多線程涉及上下文的切換,會額外有一些開銷,所以反而還慢一些。

因此在得知GIL的存在之后,有兩位勇士站了出來表示要移除GIL,當時Python處於1.5的版本,非常的古老了。當它們在去掉GIL的時候,發現多線程的效率相比之前確實提升了,但是單線程的效率只有原來的一半,這顯然是不能接受的。因為把GIL去掉了,就意味着需要更細粒度的鎖,這就會導致大量的加鎖、解鎖,而加鎖、解鎖對於操作系統來說是一個比較重量級的操作,所以GIL的移除是極其困難的。

另外還有一個關鍵,就是當GIL被移除之后,會使得擴展模塊的編寫難度大大增加。像很多現有的C擴展,在很大程度上依賴GIL提供的解決方案,如果要移除GIL,就需要重新解決這些庫的線程安全性問題。比如:我們熟知的numpy,numpy的速度之所以這么快,就是因為底層是C寫的,外面套上了一層Python的接口。而其它的庫,像pandas、scipy、sklearn都是基於numpy之上的,如果把GIL移除了,那么這些庫就都不能用了。還有深度學習,深度學習對應的庫:tensorflow、pytorch等框架所使用的底層算法也都不是Python編寫的,而是C和C++,Python只是起到了一個包裝器的作用。Python在深度學習領域很火,主要是它可以和C無縫結合,如果GIL被移除,那么這些框架也沒法用了。

還有Cython,Cython代碼本質上也是翻譯成C的代碼,再編譯成擴展模塊給Python調用,本質上也是寫擴展模塊。所以我們可以看到,如果GIL被移除了,那么很多Python第三方庫(包括上面提到的)可能就要重新洗牌了。

因此在2020年的今天,生態如此成熟的Python,幾乎是不可能擺脫GIL了。

小插曲:我們說去GIL的老鐵有兩位,分別是Greg SteinMark Hammond,這個Mark Hammond估計很多人都見過,如果沒見過,說明你Windows安裝Python的時候不怎么關注。

特別感謝 Mark Hammond,沒有它這些年無償分享的Windows專業技術,那么Python如今仍會運行在DOS上。

圖解GIL

我們說Python啟動一個線程,底層會啟動一個C線程,最終啟動一個操作系統的線程。所以還是那句話,Python中的線程實際上是封裝了C的線程,進而封裝了OS線程,一個Python線程對應一個OS線程。實際執行的肯定是OS線程,而OS線程Python解釋器是沒有權限控制的,它能控制的只是Python的線程。假設有4個Python線程,那么肯定對應4個OS線程,但是Python解釋器每次只讓一個Python線程去調用OS線程的執行,其它的線程只能干等着,只有當前的Python線程將GIL釋放了,其它的某個線程在拿到GIL時,才可以調用相應的OS線程去執行。

所以Python線程是調用C的線程、進而調用操作系統的OS線程,而每個線程在執行過程中Python解釋器是控制不了的,因為Python的控制范圍只有在解釋器這一層,Python無權干預C的線程、更無權干預OS線程。

但是注意:GIL並不是Python語言的特性,它是CPython解釋器開發人員為了方便才加上去的,只不過我們大部分用的都是CPython解釋器,所以很多人認為CPython和Python是等價的,但其實不是的。Python是一門語言,而CPython是對使用Python語言編寫的源代碼進行解釋執行的一個解釋器。而解釋器不止CPython一種,還有JPython,JPython解釋器就沒有GIL。因此Python語言本身是和GIL無關的,只不過我們平時在說Python的GIL的時候,指的都是CPython解釋器里面的GIL,這一點要注意。

所以就類似於上圖的結果,一個線程執行一會兒,另一個線程執行一會兒,至於線程怎么切換、什么時候切換,我們后面會說。

因此我們知道,對於Python而言,解釋執行字節碼是Python的核心所在,所以Python通過GIL來互斥不同線程調用解釋器執行字節碼。如果一個線程想要執行,就必須拿到GIL,而一旦拿到GIL,其他線程就無法執行了,如果想執行,那么只能等GIL釋放、被自己獲取之后才可以執行。然而實際上,GIL保護的不僅僅是Python的解釋器,同樣還有python的C API,在C/C++和Python混合開發時,在涉及到原生線程和python線程相互合作時,也需要通過GIL進行互斥。

有了GIL,在編寫多線程代碼的時候是不是就意味着不需要加鎖了呢?

答案顯然不是的,因為GIL保護的是每條字節碼不會被打斷,而一行代碼一般是對應多條字節碼的,所以每行代碼是可以被打斷的。比如:a = a + 1這樣一條語句,它是對應4條字節碼:LOAD_NAME、LOAD_CONST、BINARY_ADD、STORE_NAME。

假設此時a = 8,兩個線程同時執行 a = a + 1,線程A執行的時候已經將a和1壓入運行時棧,棧里面的a 顯然是 8。但是還沒有執行BINARY_ADD的時候,就被線程B執行了,此時B得到a顯然還是8,因為線程A還沒有對變量a做加法操作,然后B將這4條字節碼全部執行完了,所以a應該是9。但是當線程A在執行的時候,會執行BINARY_ADD,不過注意:此時棧里面的a還是8,所以加完之后還是9。

所以本來a應該是10,但是卻是9,就是因為在執行的時候發生的線程調度。所以我們在編寫多線程代碼的時候還是需要加鎖的,GIL只是保證每條字節碼執行的時候不會被打斷,但是一行代碼往往對應多條字節碼,所以我們會通過threading.Lock()再加上一把鎖。這樣即便發生了線程調度,但由於我們在Python的層面上又加了一把鎖,所以別的線程依舊無法執行。

Python會在什么情況下釋放鎖?

關於GIL的釋放Python有一個自己的調度機制:

  • 1. 當遇見io阻塞的時候會把鎖釋放,因為io阻塞是不耗費cpu的,所以此時虛擬機會把該線程的鎖釋放。
  • 2. 即便是耗費cpu的運算等等,也不會一直執行,會在執行一小段時間之后釋放鎖,為了保證其他線程都有機會執行,就類似於cpu的時間片輪轉的方式。

調度機制雖然簡單,但是這背后還隱藏這兩個問題:

  • 在何時掛起線程,選擇處於等待狀態的下一個線程?
  • 在眾多的處於等待狀態的候選線程中,選擇激活哪一個線程?

在Python的多線程機制中,這兩個問題是分別由不同的層次解決的。對於何時進行線程調度問題,是由Python自身決定的。考慮一下操作系統是如何進行進程切換的,當一個進程運行了一段時間之后,發生了時鍾中斷,操作系統響應時鍾,並開始進行進程的調度。同樣,Python中也是模擬了這樣的時鍾中斷,來激活線程的調度。我們知道Python解釋字節碼的原理就是按照指令的順序一條一條執行,而Python內部維護着一個數值,這個數值就是Python內部的時鍾。在Python2中如果一個線程執行的字節碼指令數達到了這個值,那么會進行線程切換,並且這個值在Python3中仍然存在。

import sys
# 我們看到默認是執行100條字節碼啟動線程調度機制,進行切換
# 這個方法python2、3中都存在
print(sys.getcheckinterval())  # 100

# 但是在python3中,我們更應該使用這個函數,表示線程切換的時間間隔。
# 表示一個線程在執行0.005s之后進行切換
print(sys.getswitchinterval())  # 0.005

# 上面的方法我們都可以手動設置
# 通過sys.setcheckinterval(N)和sys.setswitchinterval(N)設置即可

但是在Python3.8的時候,使用sys.getcheckinterval和sys.setcheckinterval會被警告,表示這兩個方法已經廢棄了。

除了執行時間之外,還有就是我們之前說的遇見IO阻塞的時候會進行切換,所以多線程在IO密集型還是很有用處的,說實話如果IO都不會自動切換的話,那么我覺得Python的多線程才是真的沒有用,至於為什么IO會切換我們后面說,總是現在我們知道Python會在什么時候進行線程切換了。那么下面的問題就是,Python在切換的時候會從等待的線程中選擇哪一個呢?對於這個問題,Python則是借用了底層操作系統所提供的調度機制來決定下一個進入Python解釋器的線程究竟是誰。

小結

這一次我們說了一下Python中的GIL和線程調度,我們后面會介紹線程的創建以及GIL的源碼分析。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM