1. 程式人生 > >深入理解 GIL:如何寫出高效能及執行緒安全的 Python 程式碼

深入理解 GIL:如何寫出高效能及執行緒安全的 Python 程式碼

6歲時,我有一個音樂盒。我上緊發條,音樂盒頂上的芭蕾舞女演員就會旋轉起來,同時,內部裝置發出“一閃一閃亮晶晶,滿天都是小星星”的叮鈴聲。那玩意兒肯定俗氣透了,但我喜歡那個音樂盒,我想知道它的工作原理是什麼。後來我拆開了,才看到它裡面一個簡單的裝置,機身內部鑲嵌著一個拇指大小的金屬圓筒,當它轉動時會撥弄鋼製的梳齒,從而發出這些音符。

music box parts

在一個程式設計師具備的所有特性中,想探究事物運轉規律的這種好奇心必不可少。當我開啟音樂盒,觀察內部裝置,可以看出即使我沒有成長為一個卓越的程式設計師,至少也是有好奇心的一個。

奇怪的是,我寫 Python 程式多年,一直對全域性直譯器鎖(GIL)持有錯誤的觀念,因為我從未對它的運作機理產生足夠好奇。我遇到其他對此同樣猶豫和無知的人。是時候讓我們來開啟這個盒子一窺究竟了。讓我們解讀 CPython 直譯器原始碼,找出 GIL 究竟是什麼,為什麼它存在於 Python 中,它又是怎麼影響多執行緒程式的。我將通過舉例幫助你深入理解 GIL 。你將會學到如何寫出快速執行和執行緒安全的 Python 程式碼,以及如何線上程和程序中做選擇。

(我在本文中只描述 CPython,而不是 JythonPyPy 或  IronPython。因為目前絕大多數程式設計師還是使用 CPython 實現 Python 。)

瞧,全域性直譯器鎖(GIL)

這裡:

1 staticPyThread_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 直譯器啟動時它初始化:

123456 voidPyEval_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或者執行緒模組中的其他同步物件;執行緒處於這種狀態也稱之為”睡眠“。

hand with writing

執行緒何時切換?一個執行緒無論何時開始睡眠或等待網路 I/O,其他執行緒總有機會獲取 GIL 執行 Python 程式碼。這是協同式多工處理。CPython 也還有搶佔式多工處理。如果一個執行緒不間斷地在 Python 2 中執行 1000 位元組碼指令,或者不間斷地在 Python 3 執行15 毫秒,那麼它便會放棄 GIL,而其他執行緒可以執行。把這想象成舊日有多個執行緒但只有一個 CPU 時的時間片。我將具體討論這兩種多工處理。

把 Python 看作是舊時的大型主機,多個任務共用一個CPU。

協同式多工處理

當一項任務比如網路 I/O啟動,而在長的或不確定的時間,沒有執行任何 Python 程式碼的需要,一個執行緒便會讓出GIL,從而其他執行緒可以獲取 GIL 而執行 Python。這種禮貌行為稱為協同式多工處理,它允許併發;多個執行緒同時等待不同事件。

也就是說兩個執行緒各自分別連線一個套接字:
1234567 def do_connect():s=socket.socket()s.connect(('python.org',80))# drop the GILforiinrange(2):t=threading.Thread(target=do_connect)t.start()

兩個執行緒在同一時刻只能有一個執行 Python ,但一旦執行緒開始連線,它就會放棄 GIL ,這樣其他執行緒就可以執行。這意味著兩個執行緒可以併發等待套接字連線,這是一件好事。在同樣的時間內它們可以做更多的工作。

讓我們開啟盒子,看看一個執行緒在連線建立時實際是如何放棄 GIL 的,在 socketmodule.c 中:

1234567891011121314151617 /* s.connect((host, port)) method */staticPyObject *sock_connect(PySocketSockObject *s,PyObject *addro){sock_addr_t addrbuf;intaddrlen;intres;/* convert (host, port) tuple to C address */getsockaddrarg(s,addro,SAS2SA(&addrbuf),&addrlen);Py_BEGIN_ALLOW_THREADSres=connect(s->sock_fd,addr,addrlen);Py_END_ALLOW_THREADS/* error handling and so on .... */}

執行緒正是在Py_BEGIN_ALLOW_THREADS 巨集處放棄 GIL;它被簡單定義為:

1 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,而不需要經過正在執行程式碼的執行緒允許,這樣其他執行緒便能執行:

1234567891011121314151617 for(;;){if(--ticker<0){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() 函式簡單,但+ =不是原子操作。你怎麼知道哪些操作是原子的,哪些不是? 看看這個程式碼:
12345 n=0def foo():globalnn+=1

我們可以看到這個函式用 Python 的標準 dis 模組編譯的位元組碼:

123456 >>>import dis>>>dis.dis(foo)LOAD_GLOBAL0(n)LOAD_CONST1(1)INPLACE_ADDSTORE_GLOBAL0(n)

程式碼的一行中, n += 1,被編譯成 4 個位元組碼,進行 4 個基本操作:

  1. 將 n 值載入到堆疊上
  2. 將常數 1 載入到堆疊上
  3. 將堆疊頂部的兩個值相加
  4. 將總和儲存回 n
記住,一個執行緒每執行 1000 位元組碼,就會被直譯器打斷奪走 GIL 。如果運氣不好,這(打斷)可能發生線上程載入 n 值到堆疊期間,以及把它儲存回 n 期間。很容易可以看到這個過程會如何導致更新丟失:
123456789101112 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,你仍然需要加鎖來保護共享的可變狀態:
1234567 n=0lock=threading.Lock()def foo():globalnwith lock:n+=1

如果我們使用一個原子操作比如 sort() 函式會如何呢?:

1234 lst=[4,1,3,2]def foo():lst.sort()

這個函式的位元組碼顯示 sort() 函式不能被中斷,因為它是原子的:

1234 >>>dis.dis(foo)LOAD_GLOBAL0(lst)LOAD_ATTR1(sort)CALL_FUNCTION0

一行被編譯成 3 個位元組碼:

  1. 將 lst 值載入到堆疊上
  2. 將其排序方法載入到堆疊上
  3. 呼叫排序方法

即使這一行  lst.sort() 分幾個步驟呼叫 sort 自身是單個位元組碼,因此執行緒沒有機會在呼叫期間抓取 GIL 。我們可以總結為在 sort() 不需要加鎖。或者,為了避免擔心哪個操作是原子的,遵循一個簡單的原則:始終圍繞共享可變狀態的讀取和寫入加鎖。畢竟,在 Python 中獲取一個 threading.Lock 是廉價的

儘管 GIL 不能免除我們加鎖的需要,但它確實意味著沒有加細粒度的鎖的需要(所謂細粒度是指程式設計師需要自行加、解鎖來保證執行緒安全,典型代表是 Java , 而 CPthon 中是粗粒度的鎖,即語言層面本身維護著一個全域性的鎖機制,用來保證執行緒安全)。線上程自由的語言比如 Java,程式設計師努力在儘可能短的時間內加鎖存取共享資料,減輕執行緒爭奪,實現最大並行。然而因為在 Python 中執行緒無法並行執行,細粒度鎖沒有任何優勢。只要沒有執行緒保持這個鎖,比如在睡眠,等待I/O, 或者一些其他失去 GIL 操作,你應該使用盡可能粗粒度的,簡單的鎖。其他執行緒無論如何無法並行執行。

併發可以完成更快

我敢打賭你真正為的是通過多執行緒來優化你的程式。通過同時等待許多網路操作,你的任務將更快完成,那麼多執行緒會起到幫助,即使在同一時間只有一個執行緒可以執行 Python 。這就是併發,執行緒在這種情況下工作良好。

執行緒中程式碼執行更快

1234567891011121314151617 import threadingimport requestsurls=[...]def worker():whileTrue:try:url=urls.pop()except IndexError: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:

12345678910111213141516171819202122232425262728 import osimport sysnums=[1for_inrange(1000000)]chunk_size=len(nums)// 10readers=[]whilenums:chunk,nums=nums[:chunk_size],nums[chunk_size:]reader,writer=os.pipe()ifos.fork():readers.append(reader)# Parent.else:subtotal=0foriin