<em>Mac</em>Book项目 2009年学校开始实施<em>Mac</em>Book项目,所有师生配备一本<em>Mac</em>Book,并同步更新了校园无线网络。学校每周进行电脑技术更新,每月发送技术支持资料,极大改变了教学及学习方式。因此2011
2021-06-01 09:32:01
前言:
如今,大多數計算機都帶有多個核心,允許多個執行緒並行執行計算。即使處理器只有單核,也可以通過並行程式設計來提升程式的執行效率,比如在一個執行緒等待網路資料的同時,允許另一個執行緒佔用CPU完成計算操作。並行程式設計對於程式執行加速是非常重要的。
不幸的是,由於所謂的全域性直譯器鎖(“GIL”),在許多情況下,Python 一次只能執行一個執行緒。只有在一些特定的場景下,它才可以很好地執行多個執行緒。
但是哪些使用模式允許並行,哪些不允許?因此,本文將以實用性的角度解析 GIL 的工作原理,逐步深化對於GIL的認知:
太長不看版:
執行緒必須持有 GIL 才能呼叫 CPython C API。**
在直譯器中執行的 Python 程式碼,例如 x = f(1, 2),會使用這些 API。 每個 == 比較、每個整數加法、每個 list.append:都需要呼叫 CPython C API。 因此,執行緒執行 Python 程式碼時必須持有鎖。
其他執行緒無法獲取 GIL,因此無法執行,直到當前執行的執行緒釋放它,這會自動每 5ms 發生一次。
長時間執行(“阻塞”)的擴充套件程式碼會阻止自動切換。
然而,用 C(或其他低階語言)編寫的 Python 擴充套件可以顯式釋放 GIL,從而允許一個或多個執行緒與持有 GIL 的執行緒並行執行。
GIL 是 CPython 直譯器的實現的一部分,它是一個執行緒鎖:在一個給定的時間只有一個執行緒可以獲取鎖。因此,要了解 GIL 如何影響 Python 的多執行緒並行能力,我們首先需要回答一個關鍵問題:Python 執行緒何時需要持有 GIL?
考慮以下程式碼; 它在兩個執行緒中執行函數 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()
當我們使用 Sciagraph 效能分析器執行它時,執行時間線如下所示:
注意:執行緒是如何在 CPU 上等待和執行之間來回切換的:執行程式碼持有 GIL,等待執行緒正在等待 GIL。
如果 GIL 5 毫秒(或其他可設定的時間間隔)沒有釋放,Python 會告訴當前正在執行的執行緒釋放 GIL。下一個執行緒拿到GIL後就可以執行。如上圖所示,我們看到兩個執行緒之間來回切換;實際顯示的間隔長於 5 毫秒,因為取樣分析器每 47 毫秒左右取樣一次。
這就是我們最初的認知模型,或者說是對於GIL最淺層的認知:
GIL 在 Python 3.7 到 3.10 中預設每 5ms 釋放一次,從而允許其他執行緒執行:
>>> import sys >>> sys.getswitchinterval() 0.005
但是,這些版本中的GIL是盡力而為的,也就是說,其不能保證每隔5ms一定使得執行緒釋放。考慮一個簡單的虛擬碼,直譯器在執行python執行緒時的邏輯如這個虛擬碼中的死迴圈所示:只有執行完一個操作後直譯器python才會去檢查是否釋放GIL鎖。
當然,python內部的實現邏輯比這個虛擬碼複雜的多,但是遵循的原則是相同的:
while True: if time_to_release_gil(): temporarily_release_gil() run_next_python_instruction()
只要 run_next_python_instruction() 沒有完成,temporary_release_gil() 就不會被呼叫。 大多數情況下,這不會發生,因為單個操作(新增兩個整數、追加到列表等)很快就可以完成。因此,直譯器可以經常檢查是否該釋放GIL。
但是,長時間執行的操作會阻止 GIL 自動釋放。 讓我們編寫一個小的Cython拓展,Cython是一種類似 Python的語言,其程式碼會轉化成C/C++程式碼,並編譯成可以被python呼叫的形式。下邊的程式碼呼叫標準 C 庫中的 sleep() 函數:
cdef extern from "unistd.h": unsigned int sleep(unsigned int seconds) def c_sleep(unsigned int seconds): sleep(seconds)
我們可以使用 Cython 附帶的 cythonize 工具將其編譯為可匯入的 Python 擴充套件:
$ cythonize -i c_sleep.pyx ... $ ls c_sleep*.so c_sleep.cpython-39-x86_64-linux-gnu.so
接下來從一個 Python 程式中呼叫它,該程式會建立一個新執行緒,並呼叫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))
直到睡眠執行緒完成前,主執行緒無法執行;睡眠執行緒根本沒有釋放 GIL。這是因為python在呼叫底層語言(如C)所編寫的模組時是阻塞性的呼叫,只有等到呼叫返回結果之後,本條語句才算執行結束。而對 c_sleep(2) 的呼叫在2秒內沒有返回。在這2秒結束之前,Python 直譯器迴圈不會執行,因此不會檢查它是否應該自動釋放 GIL。
這是我們深化後的對GIL的認知:
time.sleep(3)使得執行緒3秒內什麼都不做。如上所述,執行時間較長的拓展程式碼會阻止GIL線上程之間的自動切換。那麼這是否意味當某一執行緒執行time.sleep()時,其他執行緒也不能執行?
讓我們試試下面的程式碼,它嘗試在主執行緒中並行執行 3 秒的睡眠和 5 秒的計算:
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() # 在主執行緒中進行5秒的計算: calc_start = time() while time() < calc_start + 5: sum(range(10000)) print("Main thread done, elapsed:", time() - program_start)
執行後的結果為:
$ 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
如果程式只能單執行緒的執行,那麼程式執行時長需要8秒,3秒用於睡眠,5秒用於計算。從上邊的結果可以看出,睡眠執行緒和主執行緒並行執行!
Sciagraph 效能分析器的輸出如下圖所示:
想要了解這個現象的原因,需要我們閱讀time.sleep的實現程式碼:
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
根據 PY_BEGIN/END_ALLOW_THREADS 的檔案,Py_BEGIN_ALLOW_THREADS會使得程式自動的釋放GIL鎖,然後去執行阻塞操作,當程式執行到Py_END_ALLOW_THREADS時才會申請GIL鎖。因此,上邊的C實現在呼叫底層作業系統睡眠函數時會顯式釋放GIL。這是GIL釋放的另一種方式,它與我們目前知道的每 5 毫秒自動切換一次是相互獨立的。
任何已釋放 GIL 並且不嘗試申請它的程式碼(比如上文的sleep()期間)都不會阻塞其他申請GIL的執行緒。 因此,只要程式能夠顯式釋放 GIL,我們可以並行執行任意數量的執行緒。
所以這是我們的第三層認知:
到目前為止,我們已經說過python呼叫的C程式碼能夠在某些情況下主動釋放GIL。但是,執行緒呼叫 CPython C API時都必須持有 GIL。
當執行緒呼叫CPython C API時必須持有GIL,只有很少的API不需要持有GIL
(CPython C API可以使得Python程式呼叫已編譯的利用C/C++編寫的程式碼片段,Python 語言和標準庫的大部分核心功能都是用 C 編寫的)
所以這是我們最終的認知模型:
當呼叫執行時間較長的,用C編寫的API時應當主動釋放GIL
python多執行緒最有用的情況是,執行緒呼叫長時間執行的C/C++/RUST程式碼,因此會長時間的不需要呼叫CPython C API,此時就可以讓執行緒釋放GIL從而允許其他執行緒執行。
不適合並行的場景:
所謂的純python程式碼,指的是程式碼只與python內建的物件,如字典,整數,列表互動,並且程式碼也不會阻塞性的呼叫底層程式碼,這樣的程式碼會頻繁地使用Python C API:
l = [] for i in range(i): l.append(i * i)
此時搞執行緒並行並沒有太大的意義
另一種不會獲得太多並行性的情況是:在C/Rust擴充套件中需要使用大量的Python C API。例如,考慮一個讀取以下字串的 JSON 解析器:
[1, 2, 3]
解析器將:
建立所有這些 Python 物件需要使用 CPython C API,因此需要持有 GIL。由於反覆佔有和釋放 GIL 會降低程式的效能,而且大多數 JSON 檔案都可以非常快速地解析。 因此,JSON解析器的開發者當然會選擇在整個處理過程結束之前都不釋放GIL,但這也導致json解析器解析期間,程式只能線性執行。
讓我們通過觀察當我們在兩個執行緒中讀取兩個大檔案時,Python的內建JSON解析器如何影響並行性來驗證這個假設。程式碼如下所示:
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()
效能分析器的結果如下所示:
很明顯,同時執行兩個json解析器時,執行緒之間完全沒有並行
到此這篇關於如何讓python程式正確高效地並行的文章就介紹到這了,更多相關 python程 高效並行內容請搜尋it145.com以前的文章或繼續瀏覽下面的相關文章希望大家以後多多支援it145.com!
相關文章
<em>Mac</em>Book项目 2009年学校开始实施<em>Mac</em>Book项目,所有师生配备一本<em>Mac</em>Book,并同步更新了校园无线网络。学校每周进行电脑技术更新,每月发送技术支持资料,极大改变了教学及学习方式。因此2011
2021-06-01 09:32:01
综合看Anker超能充系列的性价比很高,并且与不仅和iPhone12/苹果<em>Mac</em>Book很配,而且适合多设备充电需求的日常使用或差旅场景,不管是安卓还是Switch同样也能用得上它,希望这次分享能给准备购入充电器的小伙伴们有所
2021-06-01 09:31:42
除了L4WUDU与吴亦凡已经多次共事,成为了明面上的厂牌成员,吴亦凡还曾带领20XXCLUB全队参加2020年的一场音乐节,这也是20XXCLUB首次全员合照,王嗣尧Turbo、陈彦希Regi、<em>Mac</em> Ova Seas、林渝植等人全部出场。然而让
2021-06-01 09:31:34
目前应用IPFS的机构:1 谷歌<em>浏览器</em>支持IPFS分布式协议 2 万维网 (历史档案博物馆)数据库 3 火狐<em>浏览器</em>支持 IPFS分布式协议 4 EOS 等数字货币数据存储 5 美国国会图书馆,历史资料永久保存在 IPFS 6 加
2021-06-01 09:31:24
开拓者的车机是兼容苹果和<em>安卓</em>,虽然我不怎么用,但确实兼顾了我家人的很多需求:副驾的门板还配有解锁开关,有的时候老婆开车,下车的时候偶尔会忘记解锁,我在副驾驶可以自己开门:第二排设计很好,不仅配置了一个很大的
2021-06-01 09:30:48
不仅是<em>安卓</em>手机,苹果手机的降价力度也是前所未有了,iPhone12也“跳水价”了,发布价是6799元,如今已经跌至5308元,降价幅度超过1400元,最新定价确认了。iPhone12是苹果首款5G手机,同时也是全球首款5nm芯片的智能机,它
2021-06-01 09:30:45