《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不算)

不想写了

原文地址:https://www.cnblogs.com/traditional/p/13289905.html