如何讓python程序正確高效地并發(fā)
前言:
如今,大多數(shù)計(jì)算機(jī)都帶有多個(gè)內(nèi)核,允許多個(gè)線程并行運(yùn)行計(jì)算。即使處理器只有單核,也可以通過(guò)并發(fā)編程來(lái)提升程序的運(yùn)行效率,比如在一個(gè)線程等待網(wǎng)絡(luò)數(shù)據(jù)的同時(shí),允許另一個(gè)線程占用CPU完成計(jì)算操作。并發(fā)編程對(duì)于程序運(yùn)行加速是非常重要的。
不幸的是,由于所謂的全局解釋器鎖(“GIL”),在許多情況下,Python 一次只能運(yùn)行一個(gè)線程。只有在一些特定的場(chǎng)景下,它才可以很好地運(yùn)行多個(gè)線程。
但是哪些使用模式允許并行,哪些不允許?因此,本文將以實(shí)用性的角度解析 GIL 的工作原理,逐步深化對(duì)于GIL的認(rèn)知:
- 本文將由淺入深的講解GIL的工作原理,并把GIL的特性由淺入深的抽象成認(rèn)知模型從而方便理解
- 本文將給出一些實(shí)用的設(shè)計(jì)方法,幫助讀者預(yù)測(cè)并行瓶頸是否出現(xiàn)以及出現(xiàn)的位置
太長(zhǎng)不看版:
線程必須持有 GIL 才能調(diào)用 CPython C API。**
在解釋器中運(yùn)行的 Python 代碼,例如 x = f(1, 2),會(huì)使用這些 API。 每個(gè) == 比較、每個(gè)整數(shù)加法、每個(gè) list.append:都需要調(diào)用 CPython C API。 因此,線程運(yùn)行 Python 代碼時(shí)必須持有鎖。
其他線程無(wú)法獲取 GIL,因此無(wú)法運(yùn)行,直到當(dāng)前運(yùn)行的線程釋放它,這會(huì)自動(dòng)每 5ms 發(fā)生一次。
長(zhǎng)時(shí)間運(yùn)行(“阻塞”)的擴(kuò)展代碼會(huì)阻止自動(dòng)切換。
然而,用 C(或其他低級(jí)語(yǔ)言)編寫的 Python 擴(kuò)展可以顯式釋放 GIL,從而允許一個(gè)或多個(gè)線程與持有 GIL 的線程并行運(yùn)行。
python線程何時(shí)需要擁有GIL?
GIL 是 CPython 解釋器的實(shí)現(xiàn)的一部分,它是一個(gè)線程鎖:在一個(gè)給定的時(shí)間只有一個(gè)線程可以獲取鎖。因此,要了解 GIL 如何影響 Python 的多線程并行能力,我們首先需要回答一個(gè)關(guān)鍵問(wèn)題:Python 線程何時(shí)需要持有 GIL?
認(rèn)知模型1:同一時(shí)刻只有一個(gè)線程運(yùn)行python代碼
考慮以下代碼; 它在兩個(gè)線程中運(yùn)行函數(shù) go():
import threading import time def go(): start = time.time() while time.time() < start + 0.5: sum(range(10000)) def main(): threading.Thread(target=go).start() time.sleep(0.1) go() main()
當(dāng)我們使用 Sciagraph 性能分析器運(yùn)行它時(shí),執(zhí)行時(shí)間線如下所示:
注意:線程是如何在 CPU 上等待和運(yùn)行之間來(lái)回切換的:運(yùn)行代碼持有 GIL,等待線程正在等待 GIL。
如果 GIL 5 毫秒(或其他可配置的時(shí)間間隔)沒(méi)有釋放,Python 會(huì)告訴當(dāng)前正在運(yùn)行的線程釋放 GIL。下一個(gè)線程拿到GIL后就可以運(yùn)行。如上圖所示,我們看到兩個(gè)線程之間來(lái)回切換;實(shí)際顯示的間隔長(zhǎng)于 5 毫秒,因?yàn)椴蓸臃治銎髅?47 毫秒左右采樣一次。
這就是我們最初的認(rèn)知模型,或者說(shuō)是對(duì)于GIL最淺層的認(rèn)知:
- 線程必須持有 GIL 才能運(yùn)行 Python 代碼。
- 其他線程無(wú)法獲取 GIL,因此無(wú)法運(yùn)行,直到當(dāng)前運(yùn)行的線程釋放它,GIL的切換每 5ms 進(jìn)行一次。
模型2:不保證每 5 毫秒釋放一次 GIL
GIL 在 Python 3.7 到 3.10 中默認(rèn)每 5ms 釋放一次,從而允許其他線程運(yùn)行:
>>> import sys >>> sys.getswitchinterval() 0.005
但是,這些版本中的GIL是盡力而為的,也就是說(shuō),其不能保證每隔5ms一定使得線程釋放。考慮一個(gè)簡(jiǎn)單的偽代碼,解釋器在運(yùn)行python線程時(shí)的邏輯如這個(gè)偽代碼中的死循環(huán)所示:只有運(yùn)行完一個(gè)操作后解釋器python才會(huì)去檢查是否釋放GIL鎖。
當(dāng)然,python內(nèi)部的實(shí)現(xiàn)邏輯比這個(gè)偽代碼復(fù)雜的多,但是遵循的原則是相同的:
while True: if time_to_release_gil(): temporarily_release_gil() run_next_python_instruction()
只要 run_next_python_instruction() 沒(méi)有完成,temporary_release_gil() 就不會(huì)被調(diào)用。 大多數(shù)情況下,這不會(huì)發(fā)生,因?yàn)閱蝹€(gè)操作(添加兩個(gè)整數(shù)、追加到列表等)很快就可以完成。因此,解釋器可以經(jīng)常檢查是否該釋放GIL。
但是,長(zhǎng)時(shí)間運(yùn)行的操作會(huì)阻止 GIL 自動(dòng)釋放。 讓我們編寫一個(gè)小的Cython拓展,Cython是一種類似 Python的語(yǔ)言,其代碼會(huì)轉(zhuǎn)化成C/C++代碼,并編譯成可以被python調(diào)用的形式。下邊的代碼調(diào)用標(biāo)準(zhǔn) C 庫(kù)中的 sleep() 函數(shù):
cdef extern from "unistd.h": unsigned int sleep(unsigned int seconds) def c_sleep(unsigned int seconds): sleep(seconds)
我們可以使用 Cython 附帶的 cythonize 工具將其編譯為可導(dǎo)入的 Python 擴(kuò)展:
$ cythonize -i c_sleep.pyx ... $ ls c_sleep*.so c_sleep.cpython-39-x86_64-linux-gnu.so
接下來(lái)從一個(gè) Python 程序中調(diào)用它,該程序會(huì)創(chuàng)建一個(gè)新線程,并調(diào)用c_sleep()
,該新線程與主線程是并行的:
import threading import time from c_sleep import c_sleep def thread(): c_sleep(2) threading.Thread(target=thread).start() start = time.time() while time.time() < start + 2: sum(range(10000))
直到睡眠線程完成前,主線程無(wú)法運(yùn)行;睡眠線程根本沒(méi)有釋放 GIL。這是因?yàn)閜ython在調(diào)用底層語(yǔ)言(如C)所編寫的模塊時(shí)是阻塞性的調(diào)用,只有等到調(diào)用返回結(jié)果之后,本條語(yǔ)句才算執(zhí)行結(jié)束。而對(duì) c_sleep(2) 的調(diào)用在2秒內(nèi)沒(méi)有返回。在這2秒結(jié)束之前,Python 解釋器循環(huán)不會(huì)運(yùn)行,因此不會(huì)檢查它是否應(yīng)該自動(dòng)釋放 GIL。
這是我們深化后的對(duì)GIL的認(rèn)知:
- Python 線程必須持有 GIL 才能運(yùn)行代碼。
- 其他 Python 線程無(wú)法獲取 GIL,因此無(wú)法運(yùn)行,直到當(dāng)前運(yùn)行的線程釋放它,這會(huì)自動(dòng)每 5 毫秒發(fā)生一次。
- 長(zhǎng)時(shí)間運(yùn)行(“阻塞”)的擴(kuò)展代碼會(huì)阻止自動(dòng)切換。
模型3:非 Python 代碼可以顯式釋放 GIL
time.sleep(3)使得線程3秒內(nèi)什么都不做。如上所述,運(yùn)行時(shí)間較長(zhǎng)的拓展代碼會(huì)阻止GIL在線程之間的自動(dòng)切換。那么這是否意味當(dāng)某一線程運(yùn)行time.sleep()時(shí),其他線程也不能運(yùn)行?
讓我們?cè)囋囅旅娴拇a,它嘗試在主線程中并行運(yùn)行 3 秒的睡眠和 5 秒的計(jì)算:
import threading from time import time, sleep program_start = time() def thread(): sleep(3) print("Sleep thread done, elapsed:", time() - program_start) threading.Thread(target=thread).start() # 在主線程中進(jìn)行5秒的計(jì)算: calc_start = time() while time() < calc_start + 5: sum(range(10000)) print("Main thread done, elapsed:", time() - program_start)
運(yùn)行后的結(jié)果為:
$ time python gil2.py Sleep thread done, elapsed: 3.0081260204315186 Main thread done, elapsed: 5.000330924987793 real 0m5.068s user 0m4.977s sys 0m0.011s
如果程序只能單線程的運(yùn)行,那么程序運(yùn)行時(shí)長(zhǎng)需要8秒,3秒用于睡眠,5秒用于計(jì)算。從上邊的結(jié)果可以看出,睡眠線程和主線程并行運(yùn)行!
Sciagraph 性能分析器的輸出如下圖所示:
想要了解這個(gè)現(xiàn)象的原因,需要我們閱讀time.sleep的實(shí)現(xiàn)代碼:
int ret; Py_BEGIN_ALLOW_THREADS #ifdef HAVE_CLOCK_NANOSLEEP ret = clock_nanosleep(CLOCK_MONOTONIC, TIMER_ABSTIME, &timeout_abs, NULL); err = ret; #elif defined(HAVE_NANOSLEEP) ret = nanosleep(&timeout_ts, NULL); err = errno; #else ret = select(0, (fd_set *)0, (fd_set *)0, (fd_set *)0, &timeout_tv); err = errno; #endif Py_END_ALLOW_THREADS
根據(jù) PY_BEGIN/END_ALLOW_THREADS 的文檔,Py_BEGIN_ALLOW_THREADS會(huì)使得程序自動(dòng)的釋放GIL鎖,然后去執(zhí)行阻塞操作,當(dāng)程序運(yùn)行到Py_END_ALLOW_THREADS時(shí)才會(huì)申請(qǐng)GIL鎖。因此,上邊的C實(shí)現(xiàn)在調(diào)用底層操作系統(tǒng)睡眠函數(shù)時(shí)會(huì)顯式釋放GIL。這是GIL釋放的另一種方式,它與我們目前知道的每 5 毫秒自動(dòng)切換一次是相互獨(dú)立的。
任何已釋放 GIL 并且不嘗試申請(qǐng)它的代碼(比如上文的sleep()期間)都不會(huì)阻塞其他申請(qǐng)GIL的線程。 因此,只要程序能夠顯式釋放 GIL,我們可以并行運(yùn)行任意數(shù)量的線程。
所以這是我們的第三層認(rèn)知:
- 線程必須持有 GIL 才能運(yùn)行 Python 代碼。
- 其他線程無(wú)法獲取 GIL,因此無(wú)法運(yùn)行,直到當(dāng)前運(yùn)行的線程釋放它,這會(huì)自動(dòng)每 5ms 發(fā)生一次。
- 長(zhǎng)時(shí)間運(yùn)行(“阻塞”)的擴(kuò)展代碼會(huì)阻止自動(dòng)切換。
- 然而,用 C(或其他低級(jí)語(yǔ)言)編寫的 Python 擴(kuò)展可以顯式釋放 GIL,從而允許一個(gè)或多個(gè)線程與持有 GIL 的線程并行運(yùn)行。
模型4:調(diào)用 Python C API 需要 GIL
到目前為止,我們已經(jīng)說(shuō)過(guò)python調(diào)用的C代碼能夠在某些情況下主動(dòng)釋放GIL。但是,線程調(diào)用 CPython C API時(shí)都必須持有 GIL。
當(dāng)線程調(diào)用CPython C API時(shí)必須持有GIL,只有很少的API不需要持有GIL
(CPython C API可以使得Python程序調(diào)用已編譯的利用C/C++編寫的代碼片段,Python 語(yǔ)言和標(biāo)準(zhǔn)庫(kù)的大部分核心功能都是用 C 編寫的)
所以這是我們最終的認(rèn)知模型:
- 線程必須持有 GIL 才能調(diào)用 CPython C API。
- 在解釋器中運(yùn)行的 Python 代碼,例如 x = f(1, 2),會(huì)使用這些 API。 每個(gè) == 比較、每個(gè)整數(shù)加法、每個(gè) list.append:都需要調(diào)用 CPython C API。 因此,線程運(yùn)行 Python 代碼時(shí)必須持有鎖。
- 其他線程無(wú)法獲取 GIL,因此無(wú)法運(yùn)行,直到當(dāng)前運(yùn)行的線程釋放它,這會(huì)自動(dòng)每 5ms 發(fā)生一次。
- 長(zhǎng)時(shí)間運(yùn)行(“阻塞”)的擴(kuò)展代碼會(huì)阻止自動(dòng)切換。
- 然而,用 C(或其他低級(jí)語(yǔ)言)編寫的 Python 擴(kuò)展可以顯式釋放 GIL,從而允許一個(gè)或多個(gè)線程與持有 GIL 的線程并行運(yùn)行。
什么場(chǎng)景適合利用python的并發(fā)?
當(dāng)調(diào)用運(yùn)行時(shí)間較長(zhǎng)的,用C編寫的API時(shí)應(yīng)當(dāng)主動(dòng)釋放GIL
python多線程最有用的情況是,線程調(diào)用長(zhǎng)時(shí)間運(yùn)行的C/C++/RUST代碼,因此會(huì)長(zhǎng)時(shí)間的不需要調(diào)用CPython C API,此時(shí)就可以讓線程釋放GIL從而允許其他線程運(yùn)行。
不適合并發(fā)的場(chǎng)景:
所謂的純python代碼,指的是代碼只與python內(nèi)置的對(duì)象,如字典,整數(shù),列表交互,并且代碼也不會(huì)阻塞性的調(diào)用底層代碼,這樣的代碼會(huì)頻繁地使用Python C API:
l = [] for i in range(i): l.append(i * i)
此時(shí)搞線程并發(fā)并沒(méi)有太大的意義
使用Python C API的低級(jí)代碼
另一種不會(huì)獲得太多并行性的情況是:在C/Rust擴(kuò)展中需要使用大量的Python C API。例如,考慮一個(gè)讀取以下字符串的 JSON 解析器:
[1, 2, 3]
解析器將:
- 讀取幾個(gè)字節(jié),然后創(chuàng)建一個(gè) Python 列表。
- 然后它將讀取更多字節(jié),然后創(chuàng)建一個(gè) Python 整數(shù)并將其附加到列表中。
- 這種情況一直持續(xù)到數(shù)據(jù)處理完為止。
創(chuàng)建所有這些 Python 對(duì)象需要使用 CPython C API,因此需要持有 GIL。由于反復(fù)占有和釋放 GIL 會(huì)降低程序的性能,而且大多數(shù) JSON 文檔都可以非??焖俚亟馕?。 因此,JSON解析器的開發(fā)者當(dāng)然會(huì)選擇在整個(gè)處理過(guò)程結(jié)束之前都不釋放GIL,但這也導(dǎo)致json解析器解析期間,程序只能線性運(yùn)行。
讓我們通過(guò)觀察當(dāng)我們?cè)趦蓚€(gè)線程中讀取兩個(gè)大文檔時(shí),Python的內(nèi)置JSON解析器如何影響并行性來(lái)驗(yàn)證這個(gè)假設(shè)。代碼如下所示:
import json import threading def load_json(): with open("large.json") as f: return json.load(f) threading.Thread(target=load_json).start() load_json()
性能分析器的結(jié)果如下所示:
很明顯,同時(shí)運(yùn)行兩個(gè)json解析器時(shí),線程之間完全沒(méi)有并行
到此這篇關(guān)于如何讓python程序正確高效地并發(fā)的文章就介紹到這了,更多相關(guān) python程 高效并發(fā)內(nèi)容請(qǐng)搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關(guān)文章希望大家以后多多支持腳本之家!
相關(guān)文章
Pytorch中torch.unsqueeze()與torch.squeeze()函數(shù)詳細(xì)解析
torch.squeeze()這個(gè)函數(shù)主要對(duì)數(shù)據(jù)的維度進(jìn)行壓縮,去掉維數(shù)為1的的維度,下面這篇文章主要給大家介紹了關(guān)于Pytorch中torch.unsqueeze()與torch.squeeze()函數(shù)詳細(xì)的相關(guān)資料,需要的朋友可以參考下2023-02-02Python基于均值漂移算法和分水嶺算法實(shí)現(xiàn)圖像分割
圖像分割是將圖像分成若干具有獨(dú)特性質(zhì)的區(qū)域并提取感興趣目標(biāo)的技術(shù)和過(guò)程。這篇文章將詳細(xì)講解基于均值漂移算法和分水嶺算法的圖像分割,需要的可以參考一下2023-01-01如何將yolo格式轉(zhuǎn)化為voc格式:txt轉(zhuǎn)xml(親測(cè)有效)
這篇文章主要介紹了如何將yolo格式轉(zhuǎn)化為voc格式:txt轉(zhuǎn)xml,親測(cè)有效,可以使用,本文通過(guò)圖文并茂的形式給大家介紹的非常詳細(xì),感興趣的朋友參考下吧2023-12-12詳解Python連接MySQL數(shù)據(jù)庫(kù)的多種方式
這篇文章主要介紹了Python連接MySQL數(shù)據(jù)庫(kù)方式,文中通過(guò)示例代碼介紹的非常詳細(xì),對(duì)大家的學(xué)習(xí)或者工作具有一定的參考學(xué)習(xí)價(jià)值,需要的朋友們下面隨著小編來(lái)一起學(xué)習(xí)學(xué)習(xí)吧2019-04-04python中reversed與reverse的區(qū)別解析
reverse()是python中列表的一個(gè)內(nèi)置方法(在字典、字符串和元組中沒(méi)有這個(gè)內(nèi)置方法),用于列表中數(shù)據(jù)的反轉(zhuǎn),這篇文章主要介紹了python中reversed與reverse的區(qū)別,需要的朋友可以參考下2023-03-03windows環(huán)境中利用celery實(shí)現(xiàn)簡(jiǎn)單任務(wù)隊(duì)列過(guò)程解析
這篇文章主要介紹了windows環(huán)境中利用celery實(shí)現(xiàn)簡(jiǎn)單任務(wù)隊(duì)列過(guò)程解析,文中通過(guò)示例代碼介紹的非常詳細(xì),對(duì)大家的學(xué)習(xí)或者工作具有一定的參考學(xué)習(xí)價(jià)值,需要的朋友可以參考下2019-11-11