深入理解 GIL:如何寫出高性能及線程安全的 Python 代碼
6歲時,我有一個音樂盒。我上緊發條,音樂盒頂上的芭蕾舞女演員就會旋轉起來,同時,內部裝置發出「一閃一閃亮晶晶,滿天都是小星星」的叮鈴聲。那玩意兒肯定俗氣透了,但我喜歡那個音樂盒,我想知道它的工作原理是什麼。後來我拆開了,才看到它裡面一個簡單的裝置,機身內部鑲嵌著一個拇指大小的金屬圓筒,當它轉動時會撥弄鋼製的梳齒,從而發出這些音符。
在一個程序員具備的所有特性中,想探究事物運轉規律的這種好奇心必不可少。當我打開音樂盒,觀察內部裝置,可以看出即使我沒有成長為一個卓越的程序員,至少也是有好奇心的一個。
奇怪的是,我寫 Python 程序多年,一直對全局解釋器鎖(GIL)持有錯誤的觀念,因為我從未對它的運作機理產生足夠好奇。我遇到其他對此同樣猶豫和無知的人。是時候讓我們來打開這個盒子一窺究竟了。讓我們解讀 CPython 解釋器源碼,找出 GIL 究竟是什麼,為什麼它存在於 Python 中,它又是怎麼影響多線程程序的。我將通過舉例幫助你深入理解 GIL 。你將會學到如何寫出快速運行和線程安全的 Python 代碼,以及如何在線程和進程中做選擇。
(我在本文中只描述 CPython,而不是 Jython、PyPy 或 IronPython。因為目前絕大多數程序員還是使用 CPython 實現 Python 。)
瞧,全局解釋器鎖(GIL)
這裡:
static PyThread_type_lock interpreter_lock = 0; /* This is the GIL */
這一行代碼摘自 ceval.c —— CPython 2.7 解釋器的源代碼,Guido van Rossum 的注釋」This is the GIL「 添加於2003 年,但這個鎖本身可以追溯到1997年他的第一個多線程 Python 解釋器。在 Unix系統中,PyThread_type_lock 是標準 C mutex_t 鎖的別名。當 Python 解釋器啟動時它初始化:
void
PyEval_InitThreads(void)
{
interpreter_lock=PyThread_allocate_lock();
PyThread_acquire_lock(interpreter_lock);
}
解釋器中的所有 C 代碼在執行 Python 時必須保持這個鎖。Guido 最初加這個鎖是因為它使用起來簡單。而且每次從 CPython 中去除 GIL 的嘗試會耗費單線程程序太多性能,儘管去除 GIL 會帶來多線程程序性能的提升,但仍是不值得的。(前者是Guido最為關切的, 也是不去除 GIL 最重要的原因, 一個簡單的嘗試是在1999年, 最終的結果是導致單線程的程序速度下降了幾乎2倍.)
GIL 對程序中線程的影響足夠簡單,你可以在手背上寫下這個原則:「一個線程運行 Python ,而其他 N 個睡眠或者等待 I/O.」(即保證同一時刻只有一個線程對共享資源進行存取) Python 線程也可以等待threading.Lock或者線程模塊中的其他同步對象;線程處於這種狀態也稱之為」睡眠「。
線程何時切換?一個線程無論何時開始睡眠或等待網路 I/O,其他線程總有機會獲取 GIL 執行 Python 代碼。這是協同式多任務處理。CPython 也還有搶佔式多任務處理。如果一個線程不間斷地在 Python 2 中運行 1000 位元組碼指令,或者不間斷地在 Python 3 運行15 毫秒,那麼它便會放棄 GIL,而其他線程可以運行。把這想像成舊日有多個線程但只有一個 CPU 時的時間片。我將具體討論這兩種多任務處理。
把 Python 看作是舊時的大型主機,多個任務共用一個CPU。
協同式多任務處理
當一項任務比如網路 I/O啟動,而在長的或不確定的時間,沒有運行任何 Python 代碼的需要,一個線程便會讓出GIL,從而其他線程可以獲取 GIL 而運行 Python。這種禮貌行為稱為協同式多任務處理,它允許並發;多個線程同時等待不同事件。
也就是說兩個線程各自分別連接一個套接字:
def do_connect():
s=socket.socket()
s.connect(( python.org ,80))# drop the GIL
foriinrange(2):
t=threading.Thread(target=do_connect)
t.start()
兩個線程在同一時刻只能有一個執行 Python ,但一旦線程開始連接,它就會放棄 GIL ,這樣其他線程就可以運行。這意味著兩個線程可以並發等待套接字連接,這是一件好事。在同樣的時間內它們可以做更多的工作。
讓我們打開盒子,看看一個線程在連接建立時實際是如何放棄 GIL 的,在 socketmodule.c 中:
/* s.connect((host, port)) method */
staticPyObject *
sock_connect(PySocketSockObject *s,PyObject *addro)
{
sock_addr_taddrbuf;
intaddrlen;
intres;
/* convert (host, port) tuple to C address */
getsockaddrarg(s,addro,SAS2SA(&addrbuf),&addrlen);
Py_BEGIN_ALLOW_THREADS
res=connect(s->sock_fd,addr,addrlen);
Py_END_ALLOW_THREADS
/* error handling and so on .... */
}
線程正是在Py_BEGIN_ALLOW_THREADS 宏處放棄 GIL;它被簡單定義為:
PyThread_release_lock(interpreter_lock);
當然 Py_END_ALLOW_THREADS 重新獲取鎖。一個線程可能會在這個位置堵塞,等待另一個線程釋放鎖;一旦這種情況發生,等待的線程會搶奪回鎖,並恢復執行你的Python代碼。簡而言之:當N個線程在網路 I/O 堵塞,或等待重新獲取GIL,而一個線程運行Python。
下面來看一個使用協同式多任務處理快速抓取許多 URL 的完整例子。但在此之前,先對比下協同式多任務處理和其他形式的多任務處理。
搶佔式多任務處理
Python線程可以主動釋放 GIL,也可以先發制人抓取 GIL 。
讓我們回顧下 Python 是如何運行的。你的程序分兩個階段運行。首先,Python文本被編譯成一個名為位元組碼的簡單二進位格式。第二,Python解釋器的主迴路,一個名叫 pyeval_evalframeex() 的函數,流暢地讀取位元組碼,逐個執行其中的指令。
當解釋器通過位元組碼時,它會定期放棄GIL,而不需要經過正在執行代碼的線程允許,這樣其他線程便能運行:
for(;;){
if(--ticker
ticker=check_interval;
/* Give another thread a chance */
PyThread_release_lock(interpreter_lock);
/* Other threads may run now */
PyThread_acquire_lock(interpreter_lock,1);
}
bytecode= *next_instr++;
switch(bytecode){
/* execute the next instruction ... */
}
}
默認情況下,檢測間隔是1000 位元組碼。所有線程都運行相同的代碼,並以相同的方式定期從他們的鎖中抽出。在 Python 3 GIL 的實施更加複雜,檢測間隔不是一個固定數目的位元組碼,而是15 毫秒。然而,對於你的代碼,這些差異並不顯著。
Python中的線程安全
將多個線狀物編織在一起,需要技能。
如果一個線程可以隨時失去 GIL,你必須使讓代碼線程安全。 然而 Python 程序員對線程安全的看法大不同於 C 或者 Java 程序員,因為許多 Python 操作是原子的。
在列表中調用 sort(),就是原子操作的例子。線程不能在排序期間被打斷,其他線程從來看不到列表排序的部分,也不會在列表排序之前看到過期的數據。原子操作簡化了我們的生活,但也有意外。例如,+ = 似乎比 sort() 函數簡單,但+ =不是原子操作。你怎麼知道哪些操作是原子的,哪些不是?
看看這個代碼:
n=
def foo():
globaln
n+=1
我們可以看到這個函數用 Python 的標準 dis 模塊編譯的位元組碼:
>>>importdis
>>>dis.dis(foo)
LOAD_GLOBAL(n)
LOAD_CONST1(1)
INPLACE_ADD
STORE_GLOBAL(n)
代碼的一行中, n += 1,被編譯成 4 個位元組碼,進行 4 個基本操作:
將 n 值載入到堆棧上
將常數 1 載入到堆棧上
將堆棧頂部的兩個值相加
將總和存儲回 n
記住,一個線程每運行 1000 位元組碼,就會被解釋器打斷奪走 GIL 。如果運氣不好,這(打斷)可能發生在線程載入 n 值到堆棧期間,以及把它存儲回 n 期間。很容易可以看到這個過程會如何導致更新丟失:
threads=[]
foriinrange(100):
t=threading.Thread(target=foo)
threads.append(t)
fortinthreads:
t.start()
fortinthreads:
t.join()
print(n)
通常這個代碼輸出 100,因為 100 個線程每個都遞增 n 。但有時你會看到 99 或 98 ,如果一個線程的更新被另一個覆蓋。
所以,儘管有 GIL,你仍然需要加鎖來保護共享的可變狀態:
n=
lock=threading.Lock()
def foo():
globaln
withlock:
n+=1
如果我們使用一個原子操作比如 sort() 函數會如何呢?:
lst=[4,1,3,2]
def foo():
lst.sort()
這個函數的位元組碼顯示 sort() 函數不能被中斷,因為它是原子的:
>>>dis.dis(foo)
LOAD_GLOBAL(lst)
LOAD_ATTR1(sort)
CALL_FUNCTION
一行被編譯成 3 個位元組碼:
將 lst 值載入到堆棧上
將其排序方法載入到堆棧上
調用排序方法
即使這一行 lst.sort() 分幾個步驟,調用 sort 自身是單個位元組碼,因此線程沒有機會在調用期間抓取 GIL 。我們可以總結為在 sort() 不需要加鎖。或者,為了避免擔心哪個操作是原子的,遵循一個簡單的原則:始終圍繞共享可變狀態的讀取和寫入加鎖。畢竟,在 Python 中獲取一個 threading.Lock 是廉價的。
儘管 GIL 不能免除我們加鎖的需要,但它確實意味著沒有加細粒度的鎖的需要(所謂細粒度是指程序員需要自行加、解鎖來保證線程安全,典型代表是 Java , 而 CPthon 中是粗粒度的鎖,即語言層面本身維護著一個全局的鎖機制,用來保證線程安全)。在線程自由的語言比如 Java,程序員努力在儘可能短的時間內加鎖存取共享數據,減輕線程爭奪,實現最大並行。然而因為在 Python 中線程無法並行運行,細粒度鎖沒有任何優勢。只要沒有線程保持這個鎖,比如在睡眠,等待I/O, 或者一些其他失去 GIL 操作,你應該使用儘可能粗粒度的,簡單的鎖。其他線程無論如何無法並行運行。
並發可以完成更快
我敢打賭你真正為的是通過多線程來優化你的程序。通過同時等待許多網路操作,你的任務將更快完成,那麼多線程會起到幫助,即使在同一時間只有一個線程可以執行 Python 。這就是並發,線程在這種情況下工作良好。
線程中代碼運行更快
import threading
import requests
urls=[...]
def worker():
whileTrue:
try:
url=urls.pop()
exceptIndexError:
break# Done.
requests.get(url)
for_inrange(10):
t=threading.Thread(target=worker)
t.start()
正如我們所看到的,在 HTTP上面獲取一個URL中,這些線程在等待每個套接字操作時放棄 GIL,所以他們比一個線程更快完成工作。
Parallelism 並行
如果想只通過同時運行 Python 代碼,而使任務完成更快怎麼辦?這種方式稱為並行,這種情況 GIL 是禁止的。你必須使用多個進程,這種情況比線程更複雜,需要更多的內存,但它可以更好利用多個 CPU。
這個例子 fork 出 10 個進程,比只有 1 個進程要完成更快,因為進程在多核中並行運行。但是 10 個線程與 1 個線程相比,並不會完成更快,因為在一個時間點只有 1 個線程可以執行 Python:
import os
import sys
nums=[1for_inrange(1000000)]
chunk_size=len(nums)// 10
readers=[]
whilenums:
chunk,nums=nums[:chunk_size],nums[chunk_size:]
reader,writer=os.pipe()
ifos.fork():
readers.append(reader)# Parent.
else:
subtotal=
foriinchunk:# Intentionally slow code.
subtotal+=i
print( subtotal %d %subtotal)
os.write(writer,str(subtotal).encode())
sys.exit()
# Parent.
total=
forreaderinreaders:
subtotal=int(os.read(reader,1000).decode())
total+=subtotal
print("Total: %d"%total)
因為每個 fork 的進程有一個單獨的 GIL,這個程序可以把工作分派出去,並一次運行多個計算。
(Jython 和 IronPython 提供單進程的並行,但它們遠沒有充分實現 CPython 的兼容性。有軟體事務內存的 PyPy 有朝一日可以運行更快。如果你對此好奇,試試這些解釋器。)
結語
既然你已經打開了音樂盒,看到了它簡單的裝置,你明白所有你需要知道的如何寫出快速運行,線程安全的 Python 代碼。使用線程進行並發 I/O 操作,在進程中進行並行計算。這個原則足夠簡單,你甚至不需要把它寫在你的手上。
看完本文有收穫?請轉發分享給更多人
關注「Python開發者」,提升Python技能
※【快學Python3】解析器
※2017 年最流行的 15 個數據科學 Python 庫
※你是否非常想學好 Python
※Python中用函數編程 打開另一個世界的大門
TAG:Python |
※Synaptics前總裁Rick Bergman加盟AMD:助力高性能PC、遊戲和半定製業務
※高性能的 PHP 封裝的 HTTP Restful 多線程並發請求庫-MultiHttp
※開發實踐:Darkwind Media用遮擋剔除帶來更高性能Oculus Go遊戲
※Moi Composites推出適用於3D列印高性能零件的CFM工藝
※與滑板女神Leticia Bufoni暢聊音樂與運動BEATS隆重推出Powerbeats Pro:完全無線,高性能耳機
※與iMac做競爭對手:Wbin AIO曲面一體機顏值高性能好
※TARS為Spring Cloud 提供高性能的 RPC 能力
※Corsair推出高性能PC ATX機箱
※推薦書籍:Python高性能編程
※杉岩統一存儲推出SandStone AgileStore高性能引擎
※Nginx高性能的HTTP和反向代理伺服器
※Spark Connected為AR/VR發布高性能線充電解決方案The Griffin
※AMD還有Ryzen H高性能處理器:4C/8T,集成RX Vgea核顯
※MySQL使用JPA+Hibernate的9個高性能技巧
※GraalVM:新一代高性能跨語言虛擬機
※Netty-整合Protobuf高性能數據傳輸
※Intel Fellow:人工智慧與高性能計算將走向融合
※主攻高性能 日本LAILE Beatrush品牌介紹
※Garmin Instinct本能系列新品 打造高顏值高性能城市機能風
※Mercedes-AMG 正式發布GT 4-Door Coupé高性能轎跑