久久国产成人av_抖音国产毛片_a片网站免费观看_A片无码播放手机在线观看,色五月在线观看,亚洲精品m在线观看,女人自慰的免费网址,悠悠在线观看精品视频,一级日本片免费的,亚洲精品久,国产精品成人久久久久久久

分享

《源碼探秘 CPython》84. 初識GIL,、以及多個線程之間的調度機制

 古明地覺O_o 2022-12-08 發(fā)布于北京

楔子


這次我們來說一下Python的多線程,,上篇文章提到了Python的線程是對OS線程進行了一個封裝,并提供了一個線程狀態(tài)對象 PyThreadState,,來記錄OS線程的一些狀態(tài)信息,。

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

開發(fā)一個多線程應用程序是很常見的事情,很多語言都支持多線程,,有的是原生支持,,有的是通過庫來支持,。而 Python 毫無疑問也支持多線程,并且它是通過標準 threading 實現(xiàn)的,。

當然標準庫 threading 底層依賴了 _thread,,而 _thread 是一個用 C 實現(xiàn)的庫,位于 Modules/_threadmodule.c 中,。還記得這個 Modules 目錄是做什么的嗎,?它也是 Python 源碼的一部分,里面存放的都是一些用 C 實現(xiàn),、并且對性能要求較為苛刻的庫,,編譯之后就內嵌在解釋器里面了。

另外提到Python的多線程,,總會讓人想到 GIL(global interpreter lock)這個萬惡之源,,我們后面會詳細介紹。目前我們知道Python的多線程是不能利用多核的,,因為虛擬機使用一個全局解釋器鎖(GIL)來控制線程對程序的執(zhí)行,這個結果就使得無論你的CPU有多少核,,但是同時被線程調度的 CPU 只有一個,。不過底層是怎么做的呢?我們下面就來分析一下,。


GIL 與線程調度



首先我們來分析一下為什么會有GIL這個東西存在,?舉個栗子:

import dis
dis.dis("del obj")""" 0 DELETE_NAME 0 (obj) 2 LOAD_CONST 0 (None) 4 RETURN_VALUE"""

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

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

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

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

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

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

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

因此 GIL 才被稱之為是字節(jié)碼級別的互斥鎖,,它保護每條字節(jié)碼指令只有在執(zhí)行完畢之后才會發(fā)生線程調度。

所以回到上面那個 del obj 這個例子中來,由于引入了 GIL,,所以就不存在我們之前說的:在A將引用計數(shù)減一之后,,掛起A、喚醒B這一過程,。因為A已經開始了DELETE_NAME這條指令的執(zhí)行,,而在沒執(zhí)行完之前是不會發(fā)生線程調度的,后面會通過源碼進行分析,,總之此時就不會發(fā)生懸空指針的問題了,。

所以 Python 的一條字節(jié)碼指令會對應多行 C 代碼,這其中可能會涉及很多個 C 函數(shù)的調用,,我們舉個栗子:

這是 FOR_ITER 指令,,里面的邏輯非常多,當然也涉及了多個函數(shù)調用,,而且函數(shù)內部又會調用其它的函數(shù),。如果沒有 GIL,那么這些邏輯在執(zhí)行的時候,,任何一處都可能被打斷,,發(fā)生線程調度。

但是有了 GIL 就不同了,,它是施加在字節(jié)碼層面上的互斥鎖,,保證每次只有一個線程執(zhí)行字節(jié)碼指令。并且不允許指令執(zhí)行到一半時發(fā)生調度,,因此 GIL 就保證了每條指令內部的 C 邏輯整體都是原子的。

而如果沒有 GIL,,那么即使是簡單的引用計數(shù),,在計算上都有可能出問題。事實上,,GIL 最初的目的就是為了解決引用計數(shù)的安全性問題,。

因此 GIL 對于 Python 對象的內存管理來說是不可或缺的;但是還有一點需要注意,,GIL 和 Python 語言本身沒有什么關系,,它只是官方在實現(xiàn) CPython 時,為了方便管理內存所引入的一個實現(xiàn),。但是對于其它種類的 Python 解釋器則不一定需要 GIL,,比如 JPython。


GIL 有沒有可能被移除


那么,,CPython 解釋器中的 GIL 將來是否會被移除呢,?因為對于現(xiàn)在的多核 CPU 來說,GIL 無疑是進行了限制。

關于能否移除 GIL,,就我本人來看不太可能(針對 CPython),,這都幾十年了,能移除早就移除了,。

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

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

因此在得知 GIL 的存在之后,,有兩位勇士站了出來表示要移除 GIL,,當時 Python 還是 1.5 的版本,非常的古老了,。當他們在去掉 GIL 的時候,,發(fā)現(xiàn)多線程的效率相比之前確實提升了,但是單線程的效率只有原來的一半,,這顯然是不能接受的,。因為把 GIL 去掉了,就意味著需要更細粒度的鎖來解決共享數(shù)據(jù)的安全問題,,這就會導致大量的加鎖,、解鎖。而加鎖,、解鎖對于操作系統(tǒng)來說是一個比較重量級的操作,,所以 GIL 的移除是極其困難的。

另外還有一個關鍵,,就是當 GIL 被移除之后,,會使得擴展模塊的編寫難度大大增加。因為 GIL 保護的不僅僅是 Python 解釋器,,還有 Python/C API,。像很多現(xiàn)有的 C 擴展,,在很大程度上都依賴 GIL 提供的解決方案,如果要移除 GIL,,就需要重新解決這些庫的線程安全性問題,。

比如我們熟知的 numpy,numpy 的速度之所以這么快,,就是因為底層是 C 寫的,,然后封裝成 Python 的擴展模塊。而其它的庫,,像 pandas,、scipy、sklearn 都是在 numpy 之上開發(fā)的,,如果把GIL移除了,,那么這些庫就都不能用了。

還有深度學習,,像 tensorflow,、pytorch 等框架所使用的底層算法也都不是Python編寫的,而是 C 和 C++,,Python 只是起到了一個包裝器的作用,。Python在深度學習領域很火,主要是它可以和 C 無縫結合,,如果 GIL 被移除,,那么這些框架也沒法用了。

因此在 2022 年的今天,,生態(tài)如此成熟的 Python,,幾乎是不可能擺脫 GIL 了。否則這些知名的科學計算相關的庫就要重新洗牌了,,可想而知這是一個什么樣的工作量,。

小插曲:我們說去掉GIL的老鐵有兩位,分別是Greg Stein和Mark Hammond,,這個Mark Hammond估計很多人都見過。

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


圖解 GIL



Python啟動一個線程,,底層會啟動一個C線程,,最終啟動一個操作系統(tǒng)的線程。所以還是那句話,,Python的線程實際上是封裝了C的線程,,進而封裝了OS線程,一個Python線程對應一個OS線程。

實際執(zhí)行的肯定是OS線程,,而OS線程Python解釋器是沒有權限控制的,,它能控制的只是Python的線程。假設有 4 個Python線程,,那么肯定對應 4 個OS線程,,但是解釋器每次只讓一個Python線程調用OS線程去執(zhí)行,其它的線程只能干等著,,只有當前的Python線程將GIL釋放了,,其它的某個線程在拿到GIL時,才可以調用相應的OS線程去執(zhí)行,。

總結一下就是,,沒有拿到 GIL 的 Python 線程,對應的 OS 線程會處于休眠狀態(tài),;拿到 GIL 的 Python 線程,,對應的 OS 線程會從休眠狀態(tài)被喚醒。

所以Python線程是調用C的線程,、進而調用操作系統(tǒng)的OS線程,,而OS線程在執(zhí)行過程中解釋器是控制不了的。因為解釋器的控制范圍只有Python,,它無權干預C的線程,、更無權干預OS線程。

再次強調:GIL并不是Python語言的特性,,它是CPython解釋器開發(fā)人員為了方便內存管理才加上去的,,只不過我們大部分用的都是CPython解釋器,所以很多人認為CPython和Python是等價的,,但其實不是的,。

Python是一門語言,而CPython是對使用Python語言編寫的源代碼進行解釋執(zhí)行的一個解釋器,。而解釋器不止CPython一種,,還有JPython,JPython解釋器就沒有GIL,。因此Python語言本身是和GIL無關的,,只不過我們平時在說Python的GIL的時候,指的都是CPython解釋器里面的GIL,,這一點要注意,。

所以就類似于上圖,一個線程執(zhí)行一會兒,,另一個線程執(zhí)行一會兒,,至于線程怎么切換,、什么時候切換,我們后面會說,。

對于Python而言,,解釋執(zhí)行字節(jié)碼是其核心所在,所以通過GIL來互斥不同線程執(zhí)行字節(jié)碼,。如果一個線程想要執(zhí)行,,就必須拿到GIL,而一旦拿到GIL,,其他線程就無法執(zhí)行了,,如果想執(zhí)行,那么只能等GIL釋放,、被自己獲取之后才可以執(zhí)行,。并且我們說GIL保護的不僅僅是Python的解釋器,還有 Python 的 C API,,在 C/C++和 Python混合開發(fā),,涉及到原生線程和 Python 線程相互合作時,也需要通過 GIL 進行互斥,。

那么問題來了,,有了GIL,在編寫多線程代碼的時候是不是就意味著不需要加鎖了呢,?

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

假設此時 a = 8,,兩個線程同時執(zhí)行 a = a + 1,線程 A 執(zhí)行的時候已經將 a 和 1 壓入運行時棧,,棧里面的 a 指向的是 8,。但是還沒有執(zhí)行BINARY_ADD的時候,發(fā)生線程切換,,輪到線程 B 執(zhí)行,,此時 B 得到 a 顯然還是指向 8,因為線程 A 還沒有對變量 a 做加法操作,。然后 B 比較幸運,,它一次性將這 4 條字節(jié)碼全部執(zhí)行完了,,所以 a 應該指向 9,。

然后線程調度再切換回 A,,此時會執(zhí)行 BINARY_ADD,不過注意:棧里面的 a 目前指向的還是 8,,所以加完之后還是 9,。

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

GIL 什么時候被釋放

那么問題來了,,GIL 啥時候會被釋放呢?關于這一點,,Python 有一個自己的調度機制:

  • 1)當遇見 io 阻塞的時候會把鎖釋放,,因為 io 阻塞是不耗費 CPU 的,所以此時虛擬機會把該線程的鎖釋放,;

  • 2)即便是耗費 CPU 的運算,,也不會一直執(zhí)行,會在執(zhí)行一小段時間之后釋放鎖,,為了保證其他線程都有機會執(zhí)行,,就類似于 CPU 時間片輪轉的方式;

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

  • 在何時掛起線程,,選擇處于等待狀態(tài)的下一個線程?,;

  • 在眾多處于等待狀態(tài)的候選線程中,,選擇激活哪一個線程?

在Python的多線程機制中,,這兩個問題分別是由不同的層次解決的,。對于何時進行線程調度問題,是由 Python 自身決定的,??紤]一下操作系統(tǒng)是如何進行進程切換的,當一個進程運行了一段時間之后,,發(fā)生了時鐘中斷,,操作系統(tǒng)響應時鐘,,并開始進行進程的調度。

同樣,,Python也是模擬了這樣的時鐘中斷,,來激活線程的調度。我們知道Python解釋字節(jié)碼的原理就是按照指令的順序一條一條執(zhí)行,,而解釋器內部維護著一個數(shù)值,,這個數(shù)值就是Python內部的時鐘。在Python2中如果一個線程執(zhí)行的字節(jié)碼指令數(shù)達到了這個值,,那么會進行線程切換,,并且這個值在Python3中仍然存在。

import sys# 我們看到默認是執(zhí)行100條字節(jié)碼之后啟動線程調度機制,,進行切換print(sys.getcheckinterval())  # 100
# 但是在python3中,,改成了時間間隔# 表示一個線程在執(zhí)行0.005s之后進行切換print(sys.getswitchinterval()) # 0.005
# 上面的方法我們都可以手動設置# 通過sys.setcheckinterval(N)和sys.setswitchinterval(N)設置即可

在Python3.8的時候,使用 sys.getcheckinterval 和 sys.setcheckinterval會被警告,,表示這兩個方法已經廢棄了,。因為線程發(fā)生調度不再取決于執(zhí)行的字節(jié)碼條數(shù),而是時間間隔,。

除了執(zhí)行時間之外,,還有就是我們之前說的遇見 IO 阻塞的時候會進行切換,所以多線程在 IO 密集型還是很有用處的,。說實話如果 IO 都不會自動切換的話,,那么我覺得Python的多線程才是真的沒有用,至于為什么IO會切換后面說,,總是現(xiàn)在我們知道Python會在什么時候進行線程切換了,。

那么下面的問題就是,Python在切換的時候會從等待的線程中選擇哪一個呢,?對于這個問題,,Python則是借用了底層操作系統(tǒng)所提供的調度機制來決定下一個進入Python解釋器的線程究竟是誰。

能不能手動釋放 GIL

目前介紹了很多關于 GIL 的內容,,主要是為了解釋 GIL 到底是個什么東西(底層就是一個結構體實例),,以及為什么要有 GIL。那么重點來了,,我們能不能手動釋放 GIL 呢,?

答案是可以的,在用 C 寫擴展的時候可以這么做,。因為 GIL 是為了解決 Python 的內存管理而引入的,,但如果是那些不需要和 Python 代碼一起工作的 C 代碼,那么是可以在沒有 GIL 的情況下運行的。

我們知道 Python 的動態(tài)性,,是解釋器在解釋字節(jié)碼的時候所賜予的,。而用 C 寫的擴展模塊在經過編譯之后直接指向了 C 一級的結構,所以它相當于繞過了解釋器解釋執(zhí)行這一步,,因此也就失去了相應動態(tài)特性(換來的是速度的提升)。同理,,既然能繞過解釋執(zhí)行這一步,,那么就意味著也能繞過 GIL 的限制,因為 GIL 同樣是在解釋執(zhí)行字節(jié)碼的時候施加的,。

因此當我們在寫擴展時,,如果創(chuàng)建了不綁定任何 Python 對象的 C 級結構時,也就是在處理 C-only 部分時,,可以將全局解釋器鎖給釋放掉,。換句話說,我們可以繞過 GIL,,實現(xiàn)基于線程的并行,。

注意:GIL 是為了保護 Python 對象的內存管理而設置的,如果我們嘗試釋放 GIL,,那么一定一定一定不能和 Python 對象發(fā)生任何的交互,,必須是純 C 的數(shù)據(jù)結構。

《源碼探秘 CPython》系列完結之后,,會來聊一聊如何使用 C 來給 Python 寫擴展,。


小結



到目前為止,我們算是以高維的視角理解了什么是 GIL,,以及線程切換又是怎么一回事,。那么下一篇文章,我們就從源代碼的角度,,來分析 GIL 到底是如何實現(xiàn)的,。

    轉藏 分享 獻花(0

    0條評論

    發(fā)表

    請遵守用戶 評論公約

    類似文章 更多