微吼云上線多路互動(dòng)直播服務(wù) 加速多場(chǎng)景互動(dòng)直播落地
812
2025-04-01
目錄
文章目錄
目錄
Python GIL 對(duì)線程并發(fā)性能的影響
Python 的線程安全問(wèn)題
Python 的原子性操作
Python 的線程庫(kù)鎖
Python GIL 對(duì)線程并發(fā)性能的影響
說(shuō)到這里,不妨繼續(xù)引入 Python GIL 的問(wèn)題。
在多處理器時(shí)代,程序要想充分的利用計(jì)算平臺(tái)的性能,就必須按照并發(fā)方式進(jìn)行設(shè)計(jì)。但是很遺憾,對(duì)于 Python 程序而言,不管你的服務(wù)器擁有多少個(gè)處理器,任何時(shí)候總是有且只能有一個(gè)線程在運(yùn)行。這就是 GIL 為 Python 帶來(lái)的最困難的問(wèn)題。并且目前看來(lái)短時(shí)間內(nèi)這個(gè)問(wèn)題是難以得到解決的,以至于 Python 專家們通常會(huì)建議你 “不要使用多線程,請(qǐng)使用多進(jìn)程”。
Python 是解釋型語(yǔ)言,程序代碼被編譯成二進(jìn)制格式的字節(jié)碼,然后再由 Python 解釋器的主回路 pyeval_evalframeex() 邊讀取字節(jié)碼,邊逐一執(zhí)行其中的指令。顯然,解釋器在程序運(yùn)行之前對(duì)程序本身并不是完全了解的,解釋器只知道 Python 既定的規(guī)則以及在執(zhí)行過(guò)程中怎樣動(dòng)態(tài)的去遵守這些規(guī)則。Python 解釋器無(wú)法像 C/C++ 編譯器那般在程序進(jìn)入到處理器運(yùn)行之前就已經(jīng)對(duì)程序代碼擁有了全局的語(yǔ)義分析和理解能力。作為解釋型語(yǔ)言,Python 解釋器無(wú)法在程序真正運(yùn)行之前就告訴你,你的多線程代碼實(shí)現(xiàn)到底有多糟糕(隱含的邏輯錯(cuò)誤要到真正運(yùn)行時(shí)才會(huì)觸發(fā))。
你是否也曾面對(duì)過(guò)這樣的窘境,使用 Python 多線程以后,程序的執(zhí)行效率反而比使用單線程的時(shí)候更低了?即便 Python 多線程沒(méi)有完成真正的并行,那也應(yīng)該和串行的單線程差不太多才是啊?實(shí)際情況可以比你想象的更加糟糕,Python 的多線程在某些場(chǎng)景中會(huì)比單線程的效率下降 45%。這是由于 GIL 的設(shè)計(jì)缺陷導(dǎo)致的。
Python 社區(qū)認(rèn)為操作系統(tǒng)的調(diào)度器已經(jīng)非常成熟,可以直接使用,所以 Python 的線程實(shí)際上是 C 語(yǔ)言的一個(gè) pthread,并交由系統(tǒng)調(diào)度器根據(jù)調(diào)度算法和策略進(jìn)行調(diào)度。同時(shí),為了讓各線程能夠平均的獲得 CPU 時(shí)間片,Python 會(huì)自己維護(hù)一個(gè)微代碼(字節(jié)碼指令)執(zhí)行計(jì)數(shù)器(Python2:1000 字節(jié)碼指令,Python3:15 毫秒),達(dá)到一定的計(jì)數(shù)閾值后就會(huì)強(qiáng)制當(dāng)前線程釋放 GIL,讓其他線程得到進(jìn)入 CPU 的機(jī)會(huì),這意味著 GIL 的釋放與獲取是伴隨著操作系統(tǒng)線程切換一起進(jìn)行的。
這樣的模式在單處理器計(jì)算平臺(tái)中是沒(méi)有問(wèn)題的,每觸發(fā)一次線程切換,當(dāng)前線程都能夠如愿獲取 GIL 并執(zhí)行字節(jié)碼指令,所以單個(gè)處理器始終是忙碌的。但在多處理器計(jì)算平臺(tái)中這樣的模式會(huì)發(fā)生什么呢?GIL 只有一個(gè),給了在 CPU1 的當(dāng)前線程,就不能給 CPU2 的當(dāng)前線程,所以 CPU2 的當(dāng)前線程只能白白浪費(fèi) CPU 執(zhí)行時(shí)間(線程只有獲取了 GIL 才能執(zhí)行字節(jié)碼指令)。而且在多處理器計(jì)算平臺(tái)中還平添了線程切換甚至是進(jìn)程切換的各種開(kāi)銷(xiāo),賠了夫人又折兵。
綠色:CPU 的有效執(zhí)行時(shí)間
紅色:線程因?yàn)闆](méi)拿到 GIL 白白浪費(fèi)的 CPU 時(shí)間
那么,Python 的多線程到底還能不能用?就結(jié)果而言,如果業(yè)務(wù)系統(tǒng)中存在任意一個(gè) CPU 密集型的任務(wù),那么我會(huì)告訴你 “多進(jìn)程或者協(xié)程都是不錯(cuò)的選擇”。如果業(yè)務(wù)系統(tǒng)中全都是 I/O 密集型任務(wù),那么恭喜你,多線程將會(huì)起到積極的作用。
Python 多線程在 I/O 密集型場(chǎng)景中允許真正的并發(fā),是因?yàn)橐粋€(gè)等待 I/O 的當(dāng)前線程會(huì)在長(zhǎng)的或者不確定的一段時(shí)間內(nèi),可能并沒(méi)有任何 Python 代碼會(huì)被執(zhí)行,那么該線程就會(huì)將 GIL 讓出給其他處理器上的當(dāng)前線程使用(一個(gè)在 I/O,一個(gè)在執(zhí)行 Python 代碼)。這種禮貌行為稱為協(xié)同式多任務(wù)處理,它允許并發(fā)。不同的線程在等待不同的事件。
綜上,對(duì)于復(fù)雜的 Python 業(yè)務(wù)系統(tǒng)而言,分布式架構(gòu)(解耦 CPU 密集型業(yè)務(wù)和 I/O 密集型業(yè)務(wù)并分別部署到不同的服務(wù)器上進(jìn)行調(diào)優(yōu))是一個(gè)不錯(cuò)選擇。
Python 的線程安全問(wèn)題
GIL 解決的問(wèn)題本質(zhì)就是 Python 多線程的線程安全問(wèn)題(thread-safe)。從上文中我們了解到,同一進(jìn)程的多個(gè)線程間存在數(shù)據(jù)共享。為了避免內(nèi)存可見(jiàn)性的并發(fā)安全問(wèn)題,編程語(yǔ)言大多會(huì)提供用戶可控的數(shù)據(jù)的保護(hù)機(jī)制,也就是線程同步功能。使用線程同步功能,可以控制程序流以及安全訪問(wèn)共享數(shù)據(jù),從而并發(fā)執(zhí)行多個(gè)線程。常見(jiàn)的同步模型大致有以下四種:
互斥鎖:僅允許每次使用一個(gè)線程來(lái)執(zhí)行特定代碼塊或者訪問(wèn)特定的共享數(shù)據(jù)。
讀寫(xiě)鎖:允許對(duì)受保護(hù)的共享數(shù)據(jù)進(jìn)行并發(fā)讀取和獨(dú)占寫(xiě)入(多讀單寫(xiě))。要修改共享數(shù)據(jù),線程必須首先獲取互斥寫(xiě)鎖。只有釋放所有的讀鎖之后,才允許使用互斥寫(xiě)鎖。
條件變量:一直阻塞線程,直到特定的條件為真。
計(jì)數(shù)信號(hào)量:通常用來(lái)協(xié)調(diào)對(duì)共享數(shù)據(jù)的訪問(wèn)。使用計(jì)數(shù),可以限制訪問(wèn)某個(gè)信號(hào)的線程數(shù)量。達(dá)到計(jì)數(shù)閾值時(shí),信號(hào)被阻塞,直至線程執(zhí)行接收,計(jì)數(shù)減少為止。
為了線程安全,Python 提供了下列 3 種常見(jiàn)的實(shí)現(xiàn):
原子性操作
線程庫(kù)鎖(e.g. threading.Lock)
GIL
Python 的原子性操作
Python 提供的許多內(nèi)置函數(shù)都是具有原子性的,例如排序函數(shù) sort()。
>>> lst = [4, 1, 3, 2] >>> def foo(): ... lst.sort() ... >>> import dis >>> dis.dis(foo) 2 0 LOAD_GLOBAL 0 (lst) 3 LOAD_ATTR 1 (sort) 6 CALL_FUNCTION 0 9 POP_TOP 10 LOAD_CONST 0 (None) 13 RETURN_VALUE
1
2
3
4
5
6
7
8
9
10
11
12
我們使用 dis 模塊來(lái)編譯出上述代碼的字節(jié)碼,最關(guān)鍵的字節(jié)碼指令為:
LOAD_GLOBAL:將全局變量 lst 的數(shù)據(jù) load 到堆棧
LOAD_ATTR:將 sort 的實(shí)現(xiàn) load 到堆棧
CALL_FUNCTION:調(diào)用 sort 對(duì) lst 的數(shù)據(jù)進(jìn)行排序
真正執(zhí)行排序的只有 CALL_FUNCTION 一條指令,所以說(shuō)該操作具有原子性。
Python 的線程庫(kù)鎖
我們?cè)倥e個(gè)例子看看非原子操作下,怎么保證線程安全。
>>> n = 0 >>> def foo(): ... global n ... n += 1 ... >>> import dis >>> dis.dis(foo) 3 0 LOAD_GLOBAL 0 (n) 3 LOAD_CONST 1 (1) 6 INPLACE_ADD 7 STORE_GLOBAL 0 (n) 10 LOAD_CONST 0 (None) 13 RETURN_VALUE
1
2
3
4
5
6
7
8
9
10
11
12
13
代碼編譯后的字節(jié)碼指令:
將全局變量 n 的值 load 到堆棧
將常數(shù) 1 的值 load 到堆棧
在堆棧頂部將兩個(gè)數(shù)值相加
將相加結(jié)果存儲(chǔ)回全局變量 n 的地址
將常數(shù) 0(None) 的值 load 到堆棧
從堆棧頂部返回常數(shù) 0 給函數(shù)調(diào)用者
語(yǔ)句 n += 1 被編譯成了前 4 個(gè)字節(jié)碼,后兩個(gè)字節(jié)碼是 foo 函數(shù)的 return 操作,解釋器自動(dòng)添加。
我們?cè)谏衔奶岬剑琍ython2 的線程每執(zhí)行 1000 個(gè)字節(jié)碼就會(huì)被動(dòng)的讓出 GIL。現(xiàn)在假如字節(jié)碼指令 INPLACE_ADD 就是那第 1000 條指令,這時(shí)本應(yīng)該繼續(xù)執(zhí)行 STORE_GLOBAL 0 (n) 存儲(chǔ)到 n 地址的數(shù)據(jù)就被駐留在了堆棧中。如果同一時(shí)刻,變量 n 被別的處理器當(dāng)前線程中的代碼調(diào)用了。那么請(qǐng)問(wèn)現(xiàn)在的 n 還是 +=1 之后的 n 嗎?答案是此時(shí)的 n 發(fā)生了更新丟失,在兩個(gè)當(dāng)前線程中的 n 已經(jīng)不是同一個(gè) “n” 了。這就是上面我們提到過(guò)的內(nèi)存可見(jiàn)性數(shù)據(jù)安全問(wèn)題的又一個(gè)佐證。
下面的代碼正確輸出為 100,但在 Python 多線程多處理器場(chǎng)景中,可能會(huì)得到 99 或 98 的結(jié)果。
import threading n = 0 threads = [] def foo(): global n n += 1 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)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
此時(shí),Python 程序員應(yīng)該要想到使用 Python 線程庫(kù)的鎖來(lái)解決為。
import threading n = 0 lock = threading.Lock() threads = [] def foo(): global n with lock: n += 1 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)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
顯然,即便 Python 已經(jīng)存在了 GIL,但依舊要求程序員堅(jiān)持「始終為共享可變狀態(tài)的讀寫(xiě)上鎖」。至于 Python 多線程既然也實(shí)現(xiàn)諸如此類(lèi)的細(xì)粒度的鎖,為什么還要固執(zhí)的堅(jiān)持使用 GIL 這把巨大無(wú)比的鎖呢?很抱歉,除了引用官方文檔,筆者實(shí)在不能給出更多的答案了,這是一個(gè)令人著迷又深感挫折的問(wèn)題。
static PyThread_type_lock interpreter_lock = 0; /* This is the GIL */
In CPython, the global interpreter lock, or GIL, is a mutex that prevents multiple native threads from executing Python bytecodes at once. This lock is necessary primarily because CPython’s memory management is not thread-safe. (However, since the GIL exists, other Features have grown to depend on the guarantees that it enforces.)
翻譯:在 CPython(最常用的 Python 解釋器實(shí)現(xiàn))中,全局解釋器鎖(GIL)是一個(gè)全局的互斥鎖,它可以防止多線程同時(shí)執(zhí)行 Python 程序的字節(jié)碼。 這種鎖是必要的,主要因?yàn)?CPython 的內(nèi)存管理不是線程安全的。
當(dāng)然也有人嘗試過(guò)將 GIL 改廢,Greg Stein 在 1999 年提出的 “Free Threading” patch 中移除了 GIL。但結(jié)果就是單線程執(zhí)行性能下降了 40%,同時(shí)多線程的性能提升也未能達(dá)到線性增長(zhǎng)標(biāo)準(zhǔn)。至今為止有許多樂(lè)于挑戰(zhàn)的開(kāi)發(fā)者們?cè)趪L試解決這一難題,甚至發(fā)布了多種沒(méi)有 GIL 的 Python 解釋器實(shí)現(xiàn)(e.g. JPython、IronPython)。不過(guò)很可惜的是,由于這些 “特殊” 解釋器不屬于 C 語(yǔ)言生態(tài)圈,所以沒(méi)能享受到社區(qū)眾多優(yōu)秀 C 語(yǔ)言模塊的福利,也就注定無(wú)法成為主流,只能在特定的場(chǎng)景中發(fā)揮著屬于自己的特長(zhǎng)。
無(wú)論如何,GIL 作為 Python 的文化基因,深遠(yuǎn)的影響了每一位 Pythoner,但卻并不完全是正面的影響。例如:Python 程序員對(duì)多線程安全問(wèn)題的理解與任何 C 或 Java 程序員都是大相徑庭的。GIL 和 Python 原子性操作的 “溺愛(ài)” 讓大多數(shù) Python 程序員產(chǎn)生了 “Python 是原生線程安全的編程語(yǔ)言” 的幻覺(jué),并最終在大規(guī)模并發(fā)應(yīng)用場(chǎng)景中屢屢受挫。或許真是應(yīng)了那一句 “Python 的門(mén)很好進(jìn),但進(jìn)了門(mén)之后才發(fā)現(xiàn) Python 的殿堂在天上”。
那么 GIL 是萬(wàn)惡之源嗎?也不盡然,編程的世界永遠(yuǎn)是「時(shí)間和空間」的權(quán)衡,簡(jiǎn)單優(yōu)雅或許才是真正的 Python 之美。
Python 任務(wù)調(diào)度
版權(quán)聲明:本文內(nèi)容由網(wǎng)絡(luò)用戶投稿,版權(quán)歸原作者所有,本站不擁有其著作權(quán),亦不承擔(dān)相應(yīng)法律責(zé)任。如果您發(fā)現(xiàn)本站中有涉嫌抄襲或描述失實(shí)的內(nèi)容,請(qǐng)聯(lián)系我們jiasou666@gmail.com 處理,核實(shí)后本網(wǎng)站將在24小時(shí)內(nèi)刪除侵權(quán)內(nèi)容。
版權(quán)聲明:本文內(nèi)容由網(wǎng)絡(luò)用戶投稿,版權(quán)歸原作者所有,本站不擁有其著作權(quán),亦不承擔(dān)相應(yīng)法律責(zé)任。如果您發(fā)現(xiàn)本站中有涉嫌抄襲或描述失實(shí)的內(nèi)容,請(qǐng)聯(lián)系我們jiasou666@gmail.com 處理,核實(shí)后本網(wǎng)站將在24小時(shí)內(nèi)刪除侵權(quán)內(nèi)容。