1. 程式人生 > 實用技巧 >《Cython系列》8. 使用Cython並行執行

《Cython系列》8. 使用Cython並行執行

楔子

在前面的章節中,我們瞭解了Cython如何提升Python的效能,而這些效能改進通常只需要做很少的改動即可獲得。而對於陣列來說,我們學習了Cython的型別化記憶體檢視,以及如何使用它們有效的處理陣列。特別是,我們對其進行迴圈的時候,表現出來的效能和可以和C相媲美的。

而這些改進都是基於單執行緒的,在本節我們將學習Cython的多執行緒特性,來達到並行的效果。而在Cython中有一個prange函式,它可以輕鬆地幫我們將for迴圈轉為使用多個執行緒的迴圈,接入所以可用的CPU核心。使用的時候我們會看到,平常令人尷尬的CPU並行操作,prange可以有很好的表現。

不過在介紹prange之前,我們必須要先了解Python的執行時(runtime)

和本機執行緒的某些互動,這部分會涉及到全域性直譯器鎖(GIL)

執行緒並行和全域性直譯器鎖

在討論CPython基於執行緒的並行時,全域性直譯器鎖(GIL)會經常出現。根據Python的文件,我們知道GIL是一個互斥鎖,用於防止本機多個執行緒同時執行位元組碼。換句話說,GIL確保CPython在程式執行期間,同一時刻只會使用作業系統的一個執行緒。不管你的CPU是多少核,以及你開了多少個執行緒,但是同一時刻只會使用作業系統的一個執行緒、去排程一個CPU。而且GIL不僅影響Python程式碼,也會影響Python/C api。

那麼GIL為什麼會存在呢?事實上GIL之所以存在,是因為CPython的記憶體管理不是執行緒安全的。我們知道Python釋放一個物件所佔的記憶體是在這個物件的引用計數為0的時候,如果沒有GIL,那麼可能出現兩個執行緒同時釋放一個物件的情況,一個物件都不存在了還去釋放會引發很致命的錯誤。

事實上GIL從Python誕生的時候起就已經存在了,這麼多年一直沒有移除掉,而且大量的第三方庫都是基於CPython開發的,所以GIL我個人覺得是不可能移除的了。

不過我們需要強調幾點:

  • GIL對於Python物件的記憶體管理來說是不可或缺的
  • 不和Python程式碼一起工作的C程式碼是可以在沒有GIL的情況下執行的
  • GIL對於CPython直譯器是必須的,但是對於其它的Python直譯器,比如:Jython、IronPython、pypy來說,則不要GIL

因為Cython程式碼經過編譯的,而不是解釋,所以它不執行Python位元組碼。因為我們可以在Cython中建立任何不繫結Python物件的C級結構,所以在處理Cython的C-only部分時,我們就可以釋放全域性直譯器鎖。換句話說,我們可以使用Cython繞過GIL,實現基於執行緒的並行。

在使用Cython執行並行程式碼之前,我們首先需要管理GIL。Cython為此提供了兩種機制:nogil函式屬性和with nogil上下文管理器。

nogil函式屬性

我們可以告訴Cython,在GIL釋放的情況下應該呼叫C級函式,一般這個函式來自於外部庫或者使用cdef、cpdef宣告。但是注意,def函式不可以使用GIL,因為它們要和Python進行互動。

我們來看看如何釋放GIL

cdef int func(int a, double b, double complex c) nogil:
    pass

我們只需要在函式的結尾(冒號之前)加上nogil即可,但是注意:在函式中我們不可以建立Python的物件,包括型別的Python物件:比如列表、字典等等。在編譯時,Cython盡其所能確保nogil函式不接收Python中的物件,或者以其它的方式與之互動。在實踐中,這方面做得很好,但即便如此Cython編譯器並不能保證它能準確捕捉到每一個案例,因此我們在編寫nogil函式時需要時刻保持警惕。例如我們可以將Python物件轉成void *,從而將其偷運到nogil函式中。

我們也可以將外部庫的C、C++函式宣告為nogil的形式

cdef extern from "math.h":
    double sin(double x) nogil
    double cos(double x) nogil
    double tan(double x) nogil

通常情況下,外部庫的函式不會與Python物件互動,因此我們宣告nogil函式還有另一種方式:

cdef extern from "math.h" nogil:
    double sin(double x)
    double cos(double x)
    double tan(double x)

nogil上下文管理器

為了釋放和獲取GIL,Cython必須生成合適的Python/C api呼叫。而一旦GIL被釋放,那麼在和Python物件互動之前必須再度獲取GIL,因此很自然的想到了上下文管理器。

我們在呼叫一個nogil函式的時候,要釋放GIL,然後呼叫完畢之後需要再度獲取GIL,因為不是所有的函式都是nogil函式。

cdef int res 
with nogil:  # 釋放GIL
    res = func(a, b, c)
# 執行結束之後,獲取GIL    
print(res)

上面那段程式碼就表示,在呼叫nogil函式之前釋放掉GIL,然後當函式執行完畢、退出上下文管理之後,獲取GIL。而且我們的引數和返回值都要是C的型別,並且在with nogil:這個上下文管理器中也不可以使用Python物件,否則會編譯錯誤。比如:我們將下面的print函式寫在with nogil:裡面,Cython就會不開心,因為print會將內部的引數強制轉換為PyObject。

並且我們看到,我們在with nogil的外面先聲明瞭一個res,如果不宣告會怎麼樣?答案是出現編譯錯誤,因為外面不宣告的話,那麼res就是一個Python變量了。那麼在上下文中宣告可以嗎?答案是也不可以,同樣出現編譯錯誤,cdef不允許出現在nogil上下文管理器中。

我們實際演練一下吧

# 返回值如果不寫的話預設是object,所以必須指定返回值
cpdef int func(int a, int b) nogil:
    return a + b

# 我們不在with nogil上下文中呼叫也是可以的
print(func(1, 2))

cdef int res
with nogil:
    res = func(22, 33)
print(res)    
>>> import cython_test
3
55
>>> # 我們也可以在外部進行呼叫
>>> cython_test.func(22, 33)
55
>>> 

with nogil上下文管理器的一個用途是在阻塞操作期間釋放GIL,從而允許Python執行緒執行另一個代價昂貴的操作。

另外,如果裡面出現了除法該怎麼辦呢?

cpdef double func(int a, int b) nogil:
    return a / b
>>> import cython_test
>>> cython_test.func(22, 11)
2.0
>>> 
>>> cython_test.func(22, 0)
ZeroDivisionError: float division
Exception ignored in: 'cython_test.func'
ZeroDivisionError: float division
0.0
>>> 

我們看到並沒有出現異常,但我們希望在出現異常的時候能夠丟擲,該怎麼做呢?還記得之前說的方法嗎?

cpdef double func(int a, int b) nogil except ? -1:  # except ? -1要寫在nogil的後面
    return a / b
>>> import cython_test
>>> cython_test.func(22, 0)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "cython_test.pyx", line 1, in cython_test.func
    cpdef double func(int a, int b) nogil except ? -1:
  File "cython_test.pyx", line 2, in cython_test.func
    return a / b
ZeroDivisionError: float division
>>> 

如果我們是在with nogil中出現了除零錯誤,那麼Cython會生成正確的錯誤處理程式碼,並且任何錯誤都會在重新獲取GIL之後傳播。

理解GIL是什麼以及如何管理GIL是必要的,但是還不足以支援Cython的執行緒並行性,因為實際使用釋放GIL的執行緒執行程式碼還是要取決於我們。

訪問基於執行緒的並行,最簡單的方法是使用已經幫我們實現了這一點的外部庫,當呼叫這樣的執行緒並行函式時,我們只需要在with nogil上下文中使用,就可以從它們的效能中獲益。

儘管我們今天的主角是prange,但是在使用它之前,這些關於GIL的準備工作都是有必要的。

使用prange並行迴圈

prange是Cython中一個比較特殊的函式,它需要和for迴圈一起使用。一般是外層是prange,內層是range然後讓每一層都達到並行。所以這也側面說明了,每一層迴圈都是獨立的,如果第二層迴圈依賴於第一層,那麼顯然得不到正確的結果。

for i in prange(N1, nogil=True):
    for j in range(N2):
        pass
# 或者
with nogil:
    for i in prange(N1):
        for j in range(N2):
            pass

with nogil可以在任意地方使用,可以在全域性,也可以在函式裡面。說實話,我個人目前想不到這個prange要用在什麼地方,所以有興趣可以自己嘗試。總之在釋放gil的時候,內部不可以出現Python中物件(這裡的range不算)

不想寫了