如何規避GIL帶來的限制

我們已經聽說過全局解釋器鎖,我們擔心它會影響到多線程編程。實際上,儘管 Python支持多線程編程,但是在解釋器的C語言實現中,有一部分並不是線程安全的。因此它並不能完全支持並觸發執行。事實上,解釋器被一個稱之為全局解釋器鎖的東西保管著,在任意時刻只允許一個Python線程投入執行。GIL最大的問題就是Python的多線程程序並不能利用多核CPU的優勢 (比如一個使用了多個線程的計算密集型程序只會在一個單CPU上面運行)。

在討論普通的GIL之前,有一點要強調的是GIL只會影響到那些嚴重依賴CPU的程序(比如計算型的)。 如果你的程序大部分只會涉及到I/O,比如網路交互,那麼使用多線程就很合適, 因為它們大部分時間都在等待。實際上,你完全可以放心的創建幾千個Python線程, 現代操作系統運行這麼多線程沒有任何壓力,沒啥可擔心的。

而對於依賴CPU的程序,你需要弄清楚執行的計算的特點。 例如,優化底層演算法要比使用多線程運行快得多。 類似的,由於Python是解釋執行的,如果你將那些性能瓶頸代碼移到一個C語言擴展模塊中, 速度也會提升的很快。如果你要操作數組,那麼使用NumPy這樣的擴展會非常的高效。 最後,你還可以考慮下其他可選實現方案,比如PyPy,它通過一個JIT編譯器來優化執行效率 (不過在寫這本書的時候它還不能支持Python 3)。

還有一點要注意的是,線程不是專門用來優化性能的。 一個CPU依賴型程序可能會使用線程來管理一個圖形用戶界面、一個網路連接或其他服務。 這時候,GIL會產生一些問題,因為如果一個線程長期持有GIL的話會導致其他非CPU型線程一直等待。 事實上,一個寫的不好的C語言擴展會導致這個問題更加嚴重, 儘管代碼的計算部分會比之前運行的更快些。

說了這麼多,現在想說的是我們有兩種策略來解決GIL的缺點。 首先,如果你完全工作於Python環境中,你可以使用 multiprocessing 模塊來創建一個進程池, 並像協同處理器一樣的使用它。例如,假如你有如下的線程代碼:

# Performs a large calculation (CPU bound)
def some_work(args):
...
return result

# A thread that calls the above function
def some_thread():
while True:
...
r = some_work(args)
...

稍微修改下代碼,用進程池:

# Processing pool (see below for initiazation)
pool = None

# Performs a large calculation (CPU bound)
def some_work(args):
...
return result

# A thread that calls the above function
def some_thread():
while True:
...
r = pool.apply(some_work, (args))
...

# Initiaze the pool
if __name__ == __main__:
import multiprocessing
pool = multiprocessing.Pool()

這個通過使用一個技巧利用進程池解決了GIL的問題。 當一個線程想要執行CPU密集型工作時,會將任務發給進程池。 然後進程池會在另外一個進程中啟動一個單獨的Python解釋器來工作。 當線程等待結果的時候會釋放GIL。 並且,由於計算任務在單獨解釋器中執行,那麼就不會受限於GIL了。 在一個多核系統上面,你會發現這個技術可以讓你很好的利用多CPU的優勢。

另外一個解決GIL的策略是使用C擴展編程技術。 主要思想是將計算密集型任務轉移給C,跟Python獨立,在工作的時候在C代碼中釋放GIL。 這可以通過在C代碼中插入下面這樣的特殊宏來完成:

#include "Python.h"
...

PyObject *pyfunc(PyObject *self, PyObject *args) {
...
Py_BEGIN_ALLOW_THREADS
// Threaded C code
...
Py_END_ALLOW_THREADS
...
}

如果你使用其他工具訪問C語言,比如對於Cython的ctypes庫,你不需要做任何事。 例如,ctypes在調用C時會自動釋放GIL。

許多程序員在面對線程性能問題的時候,馬上就會怪罪GIL,什麼都是它的問題。 其實這樣子太不厚道也太天真了點。 作為一個真實的例子,在多線程的網路編程中神秘的 stalls 可能是因為其他原因比如一個DNS查找延時,而跟GIL毫無關係。 最後你真的需要先去搞懂你的代碼是否真的被GIL影響到。 同時還要明白GIL大部分都應該只關注CPU的處理而不是I/O.

如果你準備使用一個處理器池,注意的是這樣做涉及到數據序列化和在不同Python解釋器通信。 被執行的操作需要放在一個通過def語句定義的Python函數中,不能是lambda、閉包可調用實例等, 並且函數參數和返回值必須要兼容pickle。 同樣,要執行的任務量必須足夠大以彌補額外的通信開銷。

另外一個難點是當混合使用線程和進程池的時候會讓你很頭疼。 如果你要同時使用兩者,最好在程序啟動時,創建任何線程之前先創建一個單例的進程池。 然後線程使用同樣的進程池來進行它們的計算密集型工作。

C擴展最重要的特徵是它們和Python解釋器是保持獨立的。 也就是說,如果你準備將Python中的任務分配到C中去執行, 你需要確保C代碼的操作跟Python保持獨立, 這就意味著不要使用Python數據結構以及不要調用Python的C API。 另外一個就是你要確保C擴展所做的工作是足夠的,值得你這樣做。 也就是說C擴展擔負起了大量的計算任務,而不是少數幾個計算。

這些解決GIL的方案並不能適用於所有問題。 例如,某些類型的應用程序如果被分解為多個進程處理的話並不能很好的工作, 也不能將它的部分代碼改成C語言執行。 對於這些應用程序,你就要自己需求解決方案了 (比如多進程訪問共享內存區,多解析器運行於同一個進程等)。 或者,你還可以考慮下其他的解釋器實現,比如PyPy。

定義一個Actor任務

actor模式是一種最古老的也是最簡單的並行和分散式計算解決方案。 事實上,它天生的簡單性是它如此受歡迎的重要原因之一。 簡單來講,一個actor就是一個並發執行的任務,只是簡單的執行發送給它的消息任務。 響應這些消息時,它可能還會給其他actor發送更進一步的消息。 actor之間的通信是單向和非同步的。因此,消息發送者不知道消息是什麼時候被發送, 也不會接收到一個消息已被處理的回應或通知。

結合使用一個線程和一個隊列可以很容易的定義actor,例如:

from queue import Queue
from threading import Thread, Event

# Sentinel used for shutdown
class ActorExit(Exception):
pass

class Actor:
def __init__(self):
self._mailbox = Queue()

def send(self, msg):

Send a message to the actor

self._mailbox.put(msg)

def recv(self):

Receive an incoming message

msg = self._mailbox.get()
if msg is ActorExit:
raise ActorExit()
return msg

def close(self):

Close the actor, thus shutting it down

self.send(ActorExit)

def start(self):

Start concurrent execution

self._terminated = Event()
t = Thread(target=self._bootstrap)

t.daemon = True
t.start()

def _bootstrap(self):
try:
self.run()
except ActorExit:
pass
finally:
self._terminated.set()

def join(self):
self._terminated.wait()

def run(self):

Run method to be implemented by the user

while True:
msg = self.recv()

# Sample ActorTask
class PrintActor(Actor):
def run(self):
while True:
msg = self.recv()
print(Got:, msg)

# Sample use
p = PrintActor()
p.start()
p.send(Hello)
p.send(World)
p.close()
p.join()

這個例子中,你使用actor實例的 send() 方法發送消息給它們。 其機制是,這個方法會將消息放入一個隊里中, 然後將其轉交給處理被接受消息的一個內部線程。 close() 方法通過在隊列中放入一個特殊的哨兵值(ActorExit)來關閉這個actor。 用戶可以通過繼承Actor並定義實現自己處理邏輯run()方法來定義新的actor。 ActorExit 異常的使用就是用戶自定義代碼可以在需要的時候來捕獲終止請求 (異常被get()方法拋出並傳播出去)。

如果你放寬對於同步和非同步消息發送的要求, 類actor對象還可以通過生成器來簡化定義。例如:

def print_actor():
while True:

try:
msg = yield # Get a message
print(Got:, msg)
except GeneratorExit:
print(Actor terminating)

# Sample use
p = print_actor()
next(p) # Advance to the yield (ready to receive)
p.send(Hello)
p.send(World)
p.close()

實現消息發布/訂閱類型

要實現發布/訂閱的消息通信模式, 你通常要引入一個單獨的「交換機」或「網關」對象作為所有消息的中介。 也就是說,不直接將消息從一個任務發送到另一個,而是將其發送給交換機, 然後由交換機將它發送給一個或多個被關聯任務。下面是一個非常簡單的交換機實現例子:

from collections import defaultdict

class Exchange:
def __init__(self):
self._subscribers = set()

def attach(self, task):
self._subscribers.add(task)

def detach(self, task):
self._subscribers.remove(task)

def send(self, msg):
for subscriber in self._subscribers:
subscriber.send(msg)

# Dictionary of all created exchanges
_exchanges = defaultdict(Exchange)

# Return the Exchange instance associated with a given name
def get_exchange(name):
return _exchanges[name]

一個交換機就是一個普通對象,負責維護一個活躍的訂閱者集合,並為綁定、解綁和發送消息提供相應的方法。 每個交換機通過一個名稱定位,get_exchange()通過給定一個名稱返回相應的Exchange實例。比如像下面這段代碼一樣,它演示了如何使用一個虛擬機:

# Example of a task. Any object with a send() method

class Task:
...
def send(self, msg):
...

task_a = Task()
task_b = Task()

# Example of getting an exchange
exc = get_exchange(name)

# Examples of subscribing tasks to it
exc.attach(task_a)
exc.attach(task_b)

# Example of sending messages
exc.send(msg1)
exc.send(msg2)

# Example of unsubscribing
exc.detach(task_a)
exc.detach(task_b)

通過隊列發送消息的任務或線程的模式很容易被實現並且也非常普遍。 不過,使用發布/訂閱模式的好處更加明顯。

首先,使用一個交換機可以簡化大部分涉及到線程通信的工作。 無需去寫通過多進程模塊來操作多個線程,你只需要使用這個交換機來連接它們。 某種程度上,這個就跟日誌模塊的工作原理類似。 實際上,它可以輕鬆的解耦程序中多個任務。

其次,交換機廣播消息給多個訂閱者的能力帶來了一個全新的通信模式。 例如,你可以使用多任務系統、廣播或扇出。 你還可以通過以普通訂閱者身份綁定來構建調試和診斷工具。 例如,下面是一個簡單的診斷類,可以顯示被發送的消息:

class DisplayMessages:
def __init__(self):
self.count = 0
def send(self, msg):
self.count += 1
print(msg[{}]: {!r}.format(self.count, msg))

exc = get_exchange(name)
d = DisplayMessages()
exc.attach(d)

最後還應該注意的是關於交換機的思想有很多種的擴展實現。 例如,交換機可以實現一整個消息通道集合或提供交換機名稱的模式匹配規則。 交換機還可以被擴展到分散式計算程序中(比如,將消息路由到不同機器上面的任務中去)。

參考書目

《Python CookBook》作者:【美】 David Beazley, Brian K. Jones

Github地址:

yidao620c/python3-cookbook?

github.com
圖標

推薦閱讀:
相关文章