《Cython系列》8. 使用 Cython 释放 GIL 实现并行执行

楔子

在前面的章节中,我们看到 Cython 可以将 Python 的性能提升 10 倍、100 倍、甚至 1000 倍,而这些性能的提升只需要我们做一些简单的修改即可。并且我们还了解了 Cython 的类型化 memoryview,通过类型化 memoryview,我们实现了一个比内置的 sum 函数快了 100 多倍的算法。

但以上的这些改进都是基于单线程的,这一次我们来学习 Cython 的多线程特性,如何在 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 这个东西存在?看两行代码:

import dis

dis.dis("del name")
"""
  1           0 DELETE_NAME              0 (name)
              2 LOAD_CONST               0 (None)
              4 RETURN_VALUE

"""

当我们使用 del 删除一个变量的时候,对应的指令是 DELETE_NAME,这个指令对应的源码有兴趣可以自己去查看一下。总之这条指令做的事情就是通过宏 Py_DECREF 减少一个对象的引用计数,并且判断减少之后其引用计数是否为 0,如果为 0 就进行回收。伪代码如下:

--obj->ob_refcnt
if (obj -> ob_refcnt == 0){
	销毁obj
}

所以总共是两步:第一步先将对象的引用计数减 1;第二步判断引用计数是否为 0,为 0 则进行销毁。那么问题来了,假设有两个线程 A 和 B,内部都引用了全局变量 obj,此时 obj 指向的对象的引用计数为 2,然后让两个线程都执行 del obj 这行代码。

其中 A 线程先执行,如果 A 线程在执行完 --obj->ob_refcnt 之后,会将对象的引用计数减一,但不幸的是这个时候调度机制将 A 挂起了,唤醒了 B。而 B 也执行 del obj,但是它比较幸运,将两步都一块执行完了。但由于之前 A 已经将引用计数减1,所以 B 线程再减 1 之后会发现对象的引用计数为0,从而执行了对象的销毁动作,内存被释放。

然后 A 又被唤醒了,此时开始执行第二个步骤,但由于 obj->ob_refcnt 已经被减少到 0,所以条件满足,那么 A 依旧会对 obj 指向的对象进行释放,但是这个对象所占内存已经被释放了,所以 obj 此时就成了悬空指针。如果再对 obj 指向的对象进行释放,最终会引发什么结果,只有天知道。

关键来了,所以 Python 引入了 GIL,GIL 施加在是解释器层面上的一把超级大锁,它是字节码级别的互斥锁,作用就是:在同时一刻,只让一个线程执行字节码,并且保证每一条字节码在执行的时候都不会被打断。

所以由于 GIL 的存在,会使得线程只有把当前的某条字节码指令执行完毕之后才有可能会发生调度。因此无论是 A 还是 B,线程调度时,要么发生在 DELETE_NAME 这条指令执行之前,要么发生在 DELETE_NAME 这条指令执行完毕之后,但是不存在一条指令(不仅是DELETE_NAME,而是所有指令)执行到一半的时候发生调度。

因此 GIL 才被称之为是字节码级别的互斥锁,它是保护字节码指令只有在执行完毕之后才会发生线程调度。所以回到上面那个 del obj 这个例子中来。由于引入了 GIL,所以就不存在我们之前说的:在 A 将引用计数减一之后,挂起 A、唤醒 B 这一过程。因为A已经开始了 DELETE_NAME 这条指令的执行,所以在没执行完之前是不会发生线程调度的,此时就不会发生悬空指针的问题了。

事实上,GIL 在单核时代,其最初的目的就是为了解决引用计数的安全性问题,只不过 Python 的作者龟叔没想到多核会发展的这么快。

那么,GIL是否会被移除呢?因为对于现在的多核 CPU 来说,Python 的GIL无疑是进行了限制。

关于能否移除 GIL,从我个人的角度来说不太可能,这都几十年了,能移除早就移除了。

而且事实上,在 Python 诞生没多久,就有人发现了这一诡异之处,因为当时的程序猿发现使用多线程在计算上居然没有任何性能上的提升,反而还比单线程慢了一点。当时 Python 的官方人员直接回复:不要使用多线程,而是使用多进程。

此时站在上帝视角的我们知道,因为 GIL 的存在使得同一时刻只有一个核被使用,所以对于纯计算的代码来说,理论上多线程和单线程是没有区别的。但是由于多线程涉及上下文的切换,会额外有一些开销,所以反而还慢一些。

因此在得知 GIL 的存在之后,有两位勇士站了出来表示要移除 GIL,当时 Python 还处于1.5 的版本,非常的古老了。当它们在去掉 GIL 的时候,发现多线程的效率相比之前确实提升了,但是单线程的效率只有原来的一半,这显然是不能接受的。因为把 GIL 去掉了,就意味着需要更细粒度的锁,这就会导致大量的加锁、解锁,而加锁、解锁对于操作系统来说是一个比较重量级的操作,所以 GIL 的移除是极其困难的。

另外还有一个关键,就是当 GIL 被移除之后,会使得扩展模块的编写难度大大增加。像很多现有的 C 扩展,在很大程度上依赖 GIL 提供的解决方案,如果要移除 GIL,就需要重新解决这些库的线程安全性问题。比如:我们熟知的 Numpy,Pandas、Scipy,甚至是 TensorFlow、Torch 等深度学习框架,如果GIL被移除,那么这些框架也没法用了。

所以我们可以看到,如果 GIL 被移除了,那么很多知名的 Python 第三方库可能就要重新洗牌了。因此在 2020 年的今天,生态如此成熟的 Python,几乎是不可能摆脱 GIL 了。

然后是 Python 中的线程模型,我们知道 Python 启动一个线程,底层会启动一个 C 线程,最终启动一个操作系统的线程。所以 Python 中的线程实际上是封装了 C 的线程,进而封装了 OS 线程,因此一个 Python 线程最终对应一个 OS 线程。实际执行的肯定是 OS 线程,而 OS 线程 Python 解释器是没有权限控制的,它能控制的只是 Python 的线程。假设有 4 个 Python 线程,那么肯定对应 4 个 OS 线程,但是 Python 解释器每次只让一个 Python 线程去调用 OS 线程执行,其它的线程只能干等着,只有当前的 Python 线程将 GIL 释放了,其它的某个线程在拿到 GIL 时,才可以调用相应的 OS 线程去执行。

所以 Python 线程是调用 C 的线程、进而调用操作系统的 OS 线程,而每个线程在执行过程中 Python 解释器是控制不了的,因为 Python 的控制范围只有在解释器这一层,Python 无权干预 C 的线程、更无权干预 OS 线程。

但是注意:GIL 并不是 Python 语言的特性,它是 CPython 解释器开发人员为了方便内存管理才加上去的,只不过我们大部分用的都是 CPython 解释器,所以很多人认为 GIL 是 Python 语言的一个特性,但其实不是的。Python 只是一门语言,而 CPython 是对使用 Python 语言编写的源代码进行解释执行的一个解释器。而解释器不止 CPython 一种,还有 JPython、PyPy 等等,而 JPython解释器就没有GIL。因此 Python 语言本身是和 GIL 无关的,只不过我们平时在说 Python 的 GIL 的时候,指的都是 CPython 解释器里面的 GIL,这一点要注意。

所以就类似于上图的结果,一个线程执行一会儿,另一个线程执行一会儿。

因此我们知道,对于 Python 而言,解释执行字节码是 Python 的核心所在,所以 CPython 通过 GIL 来互斥不同线程执行字节码。如果一个线程想要执行,就必须拿到 GIL,而一旦拿到 GIL,其他线程就无法执行了,如果想执行,那么只能等当前线程将 GIL 释放、被自己获取之后才可以执行。然而实际上,GIL 保护的不仅仅是 Python 的解释器,同样还有 Python 的 C API,在 Python 和 C/C++ 混合开发时,如果涉及到原生线程和 Python 线程相互合作,也需要通过 GIL 进行互斥。

那么 Python 会在什么情况下释放锁?

关于 GIL 的释放 Python 有一个自己的调度机制:

  • 1. 当遇见 io 阻塞的时候会把锁释放, 因为 io 阻塞是不耗费 CPU 的, 所以此时虚拟机会把该线程的锁释放;
  • 2. 即便是耗费 CPU 的运算等等, 也不会一直执行, 会在执行一小段时间之后释放锁, 为了保证其他线程都有机会执行, 就类似于 CPU 的时间片轮转的方式;

在 Python 的多线程机制中,这两个问题是分别由不同的层次解决的,对于何时进行线程调度问题,是由 Python 自身决定的。考虑一下操作系统是如何进行进程切换的,当一个进程运行了一段时间之后,发生了时钟中断,操作系统响应时钟,并开始进行进程的调度。同样,Python 中也是模拟了这样的时钟中断,来激活线程的调度。我们知道 Python 解释字节码的原理就是按照指令的顺序一条一条执行,而 Python 内部维护着一个数值,这个数值就是 Python 内部的时钟。在 Python2 中如果一个线程执行的字节码指令数达到了这个值,那么会进行线程切换,并且这个值在 Python3 中仍然存在。

import sys
# 我们看到默认是执行 100 条字节码启动线程调度机制,进行切换
# 这个方法 Python2、3中 都存在
print(sys.getcheckinterval())  # 100

# 但是在 Python3 中,我们更应该使用这个函数,表示线程切换的时间间隔。
# 表示一个线程在执行 0.005s 之后进行切换
print(sys.getswitchinterval())  # 0.005

# 上面的方法我们都可以手动设置
# 通过sys.setcheckinterval(N)和sys.setswitchinterval(N)设置即可

但是在 Python3.8 的时候,使用 sys.getcheckinterval 和 sys.setcheckinterval 会被警告,表示这两个方法已经废弃了。

所以现在我们知道 Python 会在何时切换线程了,那下面就是在切换的时候 Python 会从那些众多等待的线程中选择哪一个呢?其实对于这一点我们无需关心,因为我们控制不了,Python 是借用了底层操作系统所提供的调度机制来决定下一个进入 Python 解释器的线程究竟是谁。

所以目前为止可以得到如下结论:

  • GIL 对于 Python 对象的内存管理来说是不可或缺的;
  • GIL 和 Python 语言本身没有什么关系, 它只是 CPython 解释器为了方便管理内存所引入的一个实现, 但是对于其它的 Python 解释器则不一定需要 GIL, 比如 JPython;

目前我们介绍了很多关于 Python GIL 的问题,主要是为了解释 GIL 到底是个什么东西(底层就是一个结构体实例),以及为什么要有 GIL。然后重点来了:在 Cython 中我们是可以释放 GIL 的,因为 GIL 是为了解决 Python 的内存管理而引入的,但如果是那些不需要和 Python 代码一起工作的 C 代码,那么是可以在没有 GIL 的情况下运行的。

因为 GIL 是字节码级别的互斥锁,显然这是在解释器解释执行字节码的时候所施加的。而且不仅是 GIL,还有 Python 的动态性,都是在解释器解释字节码的时候所赐予的。而  Cython 代码经过编译之后直接指向了 C 一级的结构,所以它相当于绕过了解释器解释执行这一步,因此也就是失去了相应动态特性(换来的是速度的提升)。那么同理,既然能绕过解释执行这一步,那么就意味着也能绕过 GIL 的限制,因为 GIL 是在解释执行字节码的时候施加的。

因此当我们在 Cython 中创建了不绑定任何 Python 对象的 C 级结构时,也就是在处理 Cython 的 C-only 部分时,我们可以将全局解释器锁给释放掉。换句话说,我们可以使用 Cython 绕过 GIL,实现基于线程的并行。

注意:GIL 是为了保护 Python 对象的内存管理而设置的,如果我们尝试释放 GIL 时,那么一定一定一定不能和 Python 对象发生任何的交互,必须是纯 C 的数据结构。

那么在 Cython 中要如何管理 GIL 呢?为此 Cython 提供了两种机制:nogil 函数属性和 with nogil 上下文管理器。

nogil 函数属性

我们可以告诉 Cython,在 GIL 释放的情况下应该并行调用 C 级函数,一般这个函数来自于外部库或者使用 cdef、cpdef 声明。但是注意,def 函数不可以没有 GIL,因为它是 Python 函数。

然后我们来看看如何释放:

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

我们只需要在函数的结尾 (冒号之前)加上 nogil 即可,然后在调用的时候就可以通过并行的方式调用,但是注意:在函数中我们不可以创建任何 Python 的对象,记住是任何 Python 对象。在编译时,Cython 尽其所能确保 nogil 函数不接收 Python 中的对象,或者以其它的方式与之交互。在实践中,这方面做得很好,如果和 Python 对象发生了交互,那么编译时会报出错误。不过话虽如此,但 cython 编译器并不能保证它可以百分百做到精确捕捉每一个这样的错误(事实上除非你是刻意不想让 cython 编译器捕捉,否则 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)

注意:我们以上只是声明了一个可以不需要 GIL 的函数,然后我们调用的话,还需要借助 with nogil 上下文管理器才能真正摆脱 GIL。

nogil 上下文管理器

为了释放和获取 GIL,Cython 必须生成合适的 Python/C API 调用。而一旦 GIL 被释放,那么便可以独立地执行 C 代码,而之后要重新和 Python 对象交互,则再度获取GIL,因此这个过程我们很自然的想到了上下文管理器。

cdef double func(int a, double b) nogil:
    return <double> a + b


def add(int a, double b):
    
    cdef double res 
    # 进入 with nogil 上下文时,会释放 GIL, 所以内部必须是不能和 Python 有任何交互的纯 C 操作
    with nogil:  
        # res 的赋值均不涉及 Python /C API,所以它们是 C 操作,可以放在 with nogil 上下文中
        res = 0.0
        res = 3.14

        # 如果在 with nogil: 里面如果出现了函数调用,那么该函数必须是使用 nogil 声明的函数
        # 而使用 nogil 声明的函数,其内部都是纯 C 操作、不涉及 Python,否则是编译不过去的
        # 但如果定义函数不使用 nogil 声明,那么即使内部不涉及 Python,也不可以在 with nogil: 上下文中调用
        # 而这里的 func 是一个 nogil 函数,因此它可以在里面被调用
        res = func(a, b)
        
    # 运行结束之后, 会再度获取 GIL
    return res 
import pyximport
pyximport.install(language_level=3)

import cython_test
print(cython_test.add(1, 2.0))  # 3.0

我们看到结果上没有任何问题,在调用 func 这个 nogil 函数之前释放掉 GIL,然后当函数执行完毕、退出上下文管理之后,再获取 GIL。而且我们的参数和返回值都要是 C 的类型,并且在 with nogil: 这个上下文管理器中也不可以使用 Python 对象,否则会编译错误。比如:我们里面加上一个 print,那么 Cython 就会很生气,因为 print 会将内部的参数强制转换为 PyObject *。

并且我们看到在 res = func(a, b) 之前,我们先在外面声明了一个 res,但如果不声明会怎么样?答案是会出现编译错误,因为如果不在外面声明的话,那么 res 就是一个 Python 变量了,因此会将结果(C 的浮点数)转成 PyFloatObject,返回其 PyObject *,这样就会涉及和 Python 的交互。那么将变量的声明写在 with nogil: 内部可以吗?答案也是不行的,因为 cdef 不允许出现在 nogil 上下文管理器中。

我们实际演示一下:

# 返回值如果不写的话默认是 object,所以必须指定一个 C 的返回值
cpdef int func(int a, int b) nogil:
    return a + b

# 我们不在 with nogil 上下文中调用也是可以的,只不过此时函数声明为 nogil 就没有太大意义了
print(func(1, 2))

# 我们也可以在全局进行调用
cdef int res
with nogil:
    res = func(22, 33)
print(res) 

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 之后进行传播。

但如果一个 nogil 函数里面大部分都是纯 C 代码,只有一小部分是 Python 代码,那么我们可以在执行到 Python 代码时获取 GIL,举个栗子:

cpdef int func(int a, int b) nogil:
    # 由于 print("-------") 涉及到 Python / C API,那么就不可以在没有 GIL 的时候执行
    # 因此该函数本来不可以声明为 nogil 函数的,但可以通过 with gil 上下文,让其获取 GIL
    # 所以对于一个 nogil 函数,如果里面出现了涉及 Python / C API,那么应该放在 with gil 上下文中
    # 否则的话,编译报错,因为 nogil 函数中出现了需要 GIL 的操作
    cdef int res = a + b;
    with gil:
        print("-------")
    return res

cdef int res
with nogil:
    res = func(11, 22)
    # 同理在 with nogil 中如果涉及了 Python / C API,我们也可以使用 with gil
    with gil:
        print(res)
        # 在 with gil 中如果有不需要 Python / C API 的操作,那么也可以继续 with nogil
        with nogil:
            res = 666
            # 同理
            with gil:
                print(res)
"""
-------
33
666
"""

# 当然上面的做法有点神经病了,因为进入 with nogil 上下文会释放 GIL、上下文结束会获取 GIL
# 进入 with gil 上下文会获取 GIL,上下文结束会释放 GIL。所以应该写成下面这种方式:
with nogil:
    res = func(11, 22)  # 并行操作
    with gil:
        print(res)
    res = 666  # 并行操作
    with gil:
        print(res)
"""
-------
33
666
"""

所以 Cython 支持我们自由操控 GIL,但需要注意的是:with nogil 上下文必须在已经持有 GIL 的情况下使用,表示要释放 GIL;with gil 上下文必须在已经释放 GIL 的情况下使用,表示要持有 GIL。比如下面的代码就是不合法的:

with gil:
    pass
"""
Trying to acquire the GIL while it is already held.
"""
# 编译会报错,因为当前已经是处于 GIL 持有的状态下的,而 with gil 又会获取 GIL
# 所以 with gil 上下文只能出现在 nogil 函数中、或者 with nogil 上下文中


with nogil:
    with nogil:
        pass
"""
Trying to release the GIL while it was previously released.
"""
# 同样的道理,因为外层的 with nogil 已经把 GIL 释放了,此时已经不再持有 GIL 了
# 而内层的 with nogil 会再次尝试释放 GIL,因此报错

自由操控 GIL 的感觉还是蛮爽的,但是不建议乱用,因为 GIL 的获取和释放是一个阻塞的线程同步操作,比较昂贵。如果只是简单的 C 计算,没有必要特意释放,只有在遇到大量的 C 计算时,才建议这么做。

可能有人觉得 Cython 咋这么 牛B 嘞,它说释放就释放,它说获取就获取,下面我们就来解释一下。

下面会涉及解释器相关的知识,可以不用看,因为这不影响我们使用 Cython 并行执行,因为 Cython 帮我们屏蔽了很多的内部细节。但如果你想知道更多细节的话,那么非常推荐。

GIL 在 C 的层面要如何释放?

首先我们必须要澄清一点,GIL 只有在多线程的情况下才会出现,如果是单线程,那么 CPython 解释器是不会创建 GIL 的。一旦我们启动了多线程,那么 GIL 就被创建了。而线程如果想安全地访问 Python 对象,那么必须要持有全局解释器锁(GIL),如果没有这个锁,那么多线程基本上算是废了,即便是最简单的操作都有可能发生问题。例如两个线程同时引用了一个对象,那么这个对象的引用计数应该增加 2,但可能出现只增加 1 的情况。

因此存在一个铁打不动的规则:单线程除外,如果是多线程,那么只有获得了 GIL 的线程才能操作 Python 对象或者调用 Python / C API。而为了保证每个线程都能有机会执行,解释器有着自己的一套规则,可以定期迫使线程释放 GIL,让其它线程有机会执行,因为线程都是抢占式的。但当出现了 IO 阻塞,会立即强制释放。

而 Python 为维护 OS 线程执行的状态信息,提供了一个线程状态对象:PyThreadState。虽然真正用来执行的线程以及状态肯定是由操作系统进行维护的,但 Python 虚拟机在运行的时候总需要其它的一些与线程相关的状态和信息,比如:是否发生了异常等等,这些信息显然操作系统没有办法提供。所以 PyThreadState 对象正是 Python 为 OS 线程准备的,在虚拟机层面保存其状态信息的对象,也就是线程状态对象。在 Python 中,当前活动的 OS 线程对应的 PyThreadState 对象可以通过调用 PyThreadState_GET 获得,有了线程状态对象之后,就可以设置一些额外信息了。

并且 Python 底层有一个全局变量,保存了当前正在活动的 PyThreadState 对象的指针。

因此以上都是一些概念性的东西,下面来看看底层是怎么做的呢?如果用大白话解释的话:

将线程状态对象保存在变量中
释放全局解释器锁
... 做一些耗时的纯 C 操作 ....
获取全局解释器锁
从变量中重新获取线程状态对象

以上在编写扩展模块的时候非常常用,因此 Python 底层提供了两个宏:

// 从名字上来看, 直译就是开始允许多线程(并行执行)
// 这一步就是释放 GIL, 意思就是这 GIL 不要也罢
Py_BEGIN_ALLOW_THREADS
    
/* 做一些耗时的纯 C 操作, 当然 IO 操作也是如此, 只不过它是解释器自动调度的, 而我们使用这两个宏很明显是为了耗时的 C 操作 */    
    
// 执行完毕之后, 如果要和 Python 对象进行交互, 那么必须要再度获取 GIL, 相当于结束多线程的并行执行
Py_END_ALLOW_THREADS    

Py_BEGIN_ALLOW_THREADS 宏会打开一个新的 block 并且定义一个隐藏的局部变量;Py_END_ALLOW_THREADS 宏则是关闭这个 block 。这两个宏还有一个高级的用途:如果 Python 编译为不支持线程的版本(几乎没见过),他们定义为空,因此保存线程状态并锁定操作。

如果支持线程,那么这个 block 会进行展开:

PyThreadState *_save;     
_save = PyEval_SaveThread();  
//...  ...     
PyEval_RestoreThread(_save); 

我们也可以使用更低级的 API 来实现这一点:

PyThreadState *_save;     
_save = PyThreadState_Swap(NULL);     
PyEval_ReleaseLock();     
//...  ...     
PyEval_AcquireLock();     
PyThreadState_Swap(_save);

当然低级的一些 API 会有一些微妙的差异,因为锁操作不一定保持全局变量的一致性,而 PyEval_RestoreThread 可以对这个全局变量进行保存和恢复。同样,如果是不支持线程的解释器,那么PyEval_SaveThread() 和 PyEval_RestoreThread() 就会不操作锁,在这种情况下 PyEval_ReleaseLock() 和 PyEval_AcquireLock() 不可用,这使得不支持线程的解释器可以动态加载支持线程的扩展。

总之全局解释器锁用于保护当前线程状态对象的指针,当释放锁并保存状态的时候,当前线程状态对象的指针必须在锁释放之前获取(因为另一个线程会获取锁,全局变量会保存新的线程状态对象的指针)。相反,在获取锁并恢复线程状态对象(将全局变量设置为其指针)时,锁必须要先获取。

但是注意了,如果直接从 C 中创建线程(pthread)的时候,它们没有对应的线程状态对象。这些线程在它们使用 Python/C API 之前必须自举,首先应该要创建线程状态对象,然后获取锁,最后保存它们的线程状态对象的指针。而从 Python2.3 之后,C 中的 pthread 可以通过 PyGILState_* 系列函数自动完成以上所有步骤。

// 为当前 C 线程创建一个线程状态对象
PyGILState_STATE gstate;  
gstate = PyGILState_Ensure();  

/* 执行你的 Python 操作*/  

/* 释放线程状态对象, 而下面就不能再有任何的 Python/C API 了 */  
PyGILState_Release(gstate);  

注意:PyGILState_* 系列函数假定只有一个进程(由 Py_Initialize() 自动创建),如果是多进程则是不支持的,因此我们也能看出多进程之间是可以利用多核的。但很明显,进程之间的通信又是一件麻烦的事情。

而我们说 Cython 干的事情本质上和这是一样的,都是编写扩展模块,只不过我们写的是 Cython 代码,而 cython 编译器可以将 Cython 代码翻译成 C 代码。而 cython 编译在翻译成 C 之后,with nogil 上下文管理器同样会被翻译成释放、获取 GIL 的 Python/C API。至于它用的到底是什么 API 显然我们并不需要关心,我们只需要知道整体的脉络即可。总之:通过底层的 Python/C API,我们可以显式地控制 GIL。但如果你真的对细节感兴趣,那么不妨将这个 pyx 文件编译成扩展模块,同时会生成一个对应的 C 文件,而这个 C 文件就是 cython 编译器对 pyx 文件的翻译结果,里面包含了所有的细节。而扩展模块正是根据这个 C 文件进行编译的,也就是编译成扩展模块的第二步,而 cython 编译器将 pyx 文件翻译成 C 文件则是第一步;如果你对自己的 C 语言水平和 Python/C API 的掌握很有自信,想要自己把握一切,那么你也可以不借助 Cython,而是自己实现第一步,也就是直接编写 C 代码。

cython 编译器是一个翻译官,将 pyx 文件转成优化的 C 文件不是一件容易的事,因为 cython 编译器要考虑很多很多事情,所以翻译之后的 C 文件内容会非常多,但是这并不影响它的效率,况且这也不是我们需要关注的点。

那么问题来了,这么做究竟能不能有效利用多核呢?我们来验证一下:

# cython_test.pyx
cdef int func() nogil:
    cdef int a = 0
    # 开启死循环, 执行计算操作
    while 0 < 1:
        a += 1
    return a

def py_func():
    # 一个包装器, 一旦进入了 with nogil: , 那么此线程的 GIL 就会被释放掉, 被其它线程获取
    # 此时两个线程会并行执行
    with nogil:
        res = func()
    return res
import pyximport
pyximport.install(language_level=3)

import threading
import cython_test

# 开启一个线程, 执行 cython_test.py_func()
t1 = threading.Thread(target=lambda : cython_test.py_func())
t1.start()

# 主线程同样开启死循环, 执行纯计算逻辑
a = 0
while True:
    a += 1

然后我在我的阿里云服务器(CentOS)上测试一下,CPU 是两核心,选择在 CentOS 上测试是因为查看 CPU 利用率会很方便,一个 top 命令即可。

我们看到两个核心基本上都跑满了,证明确实是利用了多核,如果我们不使用 with nogil 的话。

cdef int func() nogil:
    cdef int a = 0
    while 0 < 1:
        a += 1
    return a

def py_func():
    # 直接调用, 此时是不会释放 GIL 的, 虽然 func 是一个 nogil 函数, 但我们需要通过 with nogil 上下文管理, 才能释放它
    res = func()
    return res

其它代码不变,再来测试一下:

我们看到只用了一个核心。

所以如果想利用多核,那么使用 Python/C API 主动释放,而在 Cython 中可以通过 with nogil: 上下文管理来实现。进入上下文,释放 GIL,独立执行,完事了再获取 GIL 退出上下文。虽然释放掉 GIL 之后,理论上该线程是无法继续执行的,必须等待自己再次获取之后才能执行。但我们说这是扩展模块,它是 C 编译之后的二进制码,如果是通过 Python/C API 主动释放 GIL 的话,那么它就不再受 Python 解释器的制约了,因为它绕过了解释执行这一步。只有当自己再主动获取 GIL 之后,才会回到正常的 GIL 调度中来。

因此当我们需要执行一个耗时的纯 C 函数,那么便可以将其申明为 nogil 函数,然后通过 with nogil 的方式实现并行执行。我们只需要做少量额外的工作,便能够获取性能上的收益。

所以理解 GIL 以及如何管理 GIL 是非常有必要的,目前为止我们算是知道了如何在 Cython 中释放 GIL 达到并行执行的效果。但是这还不够,假设有一个循环,需要遍历 4 次,而我们的机器正好有 4 个核,我们希望这 4 层循环能够并行执行该怎么办呢?虽然我们也可以通过上面的方式实现,但明显会比较麻烦。

而我们今天的主角是 prange 便可以实现这一点,只不过为了引出它做了大量的准备工作,但这一切都是值得的。

使用 prange 并行执行循环

下面来介绍 prange,我们通过 prange 可以实现循环的并行执行。这个 prange 的特殊功能是 Cython 独一无二的,并且 prange 只能与 for 循环搭配使用,不能独立存在。

Cython 使用 OpenMP API 实现 prange,用于多平台共享内存的处理。但 OpenMP 需要 C 或者 C++ 编译器支持,并且编译时需要指定特定的编译参数来启动。例如:当我们使用 gcc 时,必须在编译和连接二进制文件的时候指定一个 -fopenmp,以确保启用 OpenMP。许多编译器均 OpenMP ,包括免费的和商业的。但 Clang/LLVM 则是一个最显著的例外,它只在一个单独的分支中得到了初步的支持,而为它完全实现的 OpenMP 还在开发当中。

而使用 prange,我们需要从 cython.parallel 中进行导入(cimport)。但是在这之前,我们先来看一个栗子:

import numpy as np
from cython cimport boundscheck, wraparound

cdef inline double norm2(double complex z) nogil:
    """
    接收一个复数 z, 计算它的模的平方, 它要被下面的 escape 函数多次调用, 这里通过 inline 声明成内联函数
    :param z: 
    :return: 
    """
    return z.real * z.real + z.imag * z.imag


cdef int escape(double complex z,
                double complex c,
                double z_max,
                int n_max) nogil:
    """
    这个函数具体做什么, 不是我们的重点, 我们不需要关心
    """
    cdef:
        int i = 0
        double z_max2 = z_max * z_max
    while norm2(z) < z_max2 and i < n_max:
        z = z * z + c
        i += 1
    return i


@boundscheck(False)
@wraparound(False)
def calc_julia(int resolution,
               double complex c,
               double bound=1.5,
               double z_max=4.0,
               int n_max=1000):
    """
    我们将要在 Python 中调用的函数
    """
    cdef:
        double step = 2.0 * bound / resolution
        int i, j
        double complex z
        double real, imag
        int[:, :: 1] counts
    counts = np.zeros((resolution + 1, resolution + 1), dtype="int32")

    for i in range(resolution + 1):
        real = -bound + i * step
        for j in range(resolution + 1):
            imag = -bound + j * step
            z = real + imag * 1j
            counts[i, j] = escape(z, c, z_max, n_max)

    return np.array(counts, copy=False)

我们来调用一下这个 calc_julia 函数,这个函数做什么不需要关系,我们只需要将注意力放在那两层 for 循环(准确的说是外层循环)上即可,这里我们采用编译的形式。

import cython_test
import numpy as np
import matplotlib.pyplot as plt
arr = cython_test.calc_julia(1000, 0.322 + 0.05j)
plt.imshow(np.log(arr))
plt.show()

那么对于 calc_julia 这个函数,耗时多少呢?我们来测试一下:

使用 prange

所以接下来我们的 prange 就闪亮登场了,很明显对于外层循环而言,它里面的逻辑是彼此独立的,当前循环不依赖上一层循环的结果,因此这非常适合并行执行。我们只需要做简单的修改即可:

# distutils: extra_compile_args = -fopenmp
# distutils: extra_link_args = -fopenmp

import numpy as np
from cython cimport boundscheck, wraparound
from cython.parallel cimport prange  # 导入 prange

cdef inline double norm2(double complex z) nogil:
    return z.real * z.real + z.imag * z.imag


cdef int escape(double complex z,
                double complex c,
                double z_max,
                int n_max) nogil:
    cdef:
        int i = 0
        double z_max2 = z_max * z_max
    while norm2(z) < z_max2 and i < n_max:
        z = z * z + c
        i += 1
    return i


@boundscheck(False)
@wraparound(False)
def calc_julia(int resolution,
               double complex c,
               double bound=1.5,
               double z_max=4.0,
               int n_max=1000):
    cdef:
        double step = 2.0 * bound / resolution
        int i, j
        double complex z
        double real, imag
        int[:, :: 1] counts
    counts = np.zeros((resolution + 1, resolution + 1), dtype="int32")
	
    # 外层循环使用 prange
    for i in prange(resolution + 1, nogil=True):
        real = -bound + i * step
        for j in range(resolution + 1):
            imag = -bound + j * step
            z = real + imag * 1j
            counts[i, j] = escape(z, c, z_max, n_max)

    return np.array(counts, copy=False)

我们只需要将外层循环的 range 换成 prange 即可,里面指定 nogil=True,至于这个函数的其它参数以及用法后面会说。通过 prange 便可以实现并行循环的效果,而之前也提到,一旦使用了 prange,那么必须确保在编译的时候启用 OpenMP,像 gcc 这样的编译器的链接标志是 -fopenmp。而在 Cython 中我们可以通过注释的方式的方式指定,直接写在 cython_test.pyx 的顶部即可,这样编译的时候会自动将将 -fopenmp 标志带过去。

编译方式和之前一样,然后导入调用时会输出一样的结果,但问题是效率上有多少提升呢?我们再来测一下。

我们看到效率大概是提升了两倍,因为我 Windows 上使用的不是 gcc,所以这里是在 CentOS 上演示的,而我的 CentOS 服务器只有两个核,因此效率提升大概两倍左右。

所以我们只是做了一些非常简单的修改,便可带来如此巨大的性能提升,简直妙啊。所以 prange 是要搭配 for 循环来使用的,如果内部的逻辑彼此独立,即第二层循环不依赖第一层循环的某些结果,那么不妨使用 prange 吧。

注意还没完,我们还能做得更好,下面就来看看 prange 里面的其它的参数,这样我们能更好利用 prange 的并行特性。

prange 的其它参数

prange 函数的原型如下:

# 第一个参数 self 我们不需要管, 这个函数实际上是在一个类里面
def prange(self, start=0, stop=None, step=1, nogil=False, schedule=None, chunksize=None, num_threads=None):

我们先来看前三个参数,start、stop、step

  • prange(3): 相当于 start=0、stop=3
  • prange(1, 3): 相当于 start=1、stop=3
  • prange(1, 3, 2): 相当于 start=1、stop=3、step=2

类似于 range,同样不包含结尾 stop,然后是第四个参数 nogil,它默认是 False,但事实上我们必须将其设置为 True,否则会报出编译错误。

然后剩下的三个参数:schedule、chunksize、num_threads,如果我们不指定的话,那么 cython编译器采取的策略是将整个循环分成多个大小相同的连续块,然后给每一个可用线程一个块。然而整个策略实际上并不是最好的,因为每一层循环用的时间不一定一样,如果一个线程很快就完成了,那么不就造成资源上的浪费了吗?

我们再来修改一下,将 schedule 指定为 "static",chunksize 指定为 1:

for i in prange(resolution + 1, nogil=True, schedule="static", chunksize=1):

其它地方不变,只是加两个参数,然后我们来重新测试一下。

我们看到效率上并没有什么提升,原因在我的机器只有两个核,如果核数再多一些的话,那么会发生速度就明显的提升。

那么我们就来解释一下剩余的三个参数的含义,首先是 schedule,它有以下几个选项:

1. static

整个循环在编译时会以一种固定的方式分配给多个线程,如果 chunksize 没有指定,那么会分成 num_threads 个连续块,一个线程一个块。如果指定了 chunksize,那么每一块会以轮询调度算法(Round Robin)交给线程进行处理,适用于任务均匀分布的情况。

2. dynamic

线程在运行时动态地向调度器申请下一个块,chunksize 默认为 1,当任务负载不均时,动态调度是最佳的选择。

3. guided

块是动态分布的,就像 dynamic 一样,但这与 dynamic 还不同,chunksize 的比例不是固定的,而是和 剩余迭代次数 / 线程数 成比例关系。

4. runtime

不常用。

控制 schedule 和 chunksize 可以方便地探索不同的并行执行策略、以及工作负载分配,通常指定 schedule 为 "static",加上设置一个合适的 chunksize 是最好的选择。而 dynamic 和 guided 适用于动态变化的执行上下文,但会导致运行时开销。

当然还有最后一个参数 num_threads,很明显不需要解释,就是使用的线程数量。如果不指定,那么 prange 会使用尽可能多的线程。

所以我们只是做了一点修改,便可以带来巨大的性能提升,这种性能提升与 Cython 在纯 Python 上带来的性能提升成倍增关系。

在 reductions 操作上使用 prange

我们经常会循环遍历数组计算它们的累和、累积等等,这种数据量减少的操作我们称之为 reduction 操作。而 prange 对这样的操作也是支持并行执行的,我们举个栗子:

from cython cimport boundscheck, wraparound
from cython.parallel cimport prange

@boundscheck(False)
@wraparound(False)
def calc_julia(int [:, :: 1] counts,
               int val):
    cdef:
        int total = 0
        int i, j, M, N
    N, M = counts.shape[: 2]
    for i in range(M):
        for j in range(N):
            if counts[i, j] == val:
                total += 1

    return total / float(counts.size)

显然我们是希望计算一个数组中值 val 的元素的个数,下面测试一下:

我们看到大概 15.1 毫秒,如果我们改成 prange 的话,看看会有什么效果呢?

# distutils: extra_compile_args = -fopenmp
# distutils: extra_link_args = -fopenmp
from cython cimport boundscheck, wraparound
from cython.parallel cimport prange

@boundscheck(False)
@wraparound(False)
def calc_julia(int [:, :: 1] counts,
               int val):
    cdef:
        int total = 0
        int i, j, M, N
    N, M = counts.shape[: 2]
    for i in prange(M, nogil=True):
        for j in range(N):
            if counts[i, j] == val:
                total += 1

    return total / float(counts.size)

速度比原来快了一倍多,还是很可观的,如果你的 CPU 是多核的,那么效率会更明显。

这里我们没有使用 schedule 和 chunksize 参数,你也可以加上去。当然啦,如果占用内存过大的话,它可能无法像预期的一样显著地提升性能,因为 prange 的优化重点是在 CPU 上面。

但是可能有人会有疑问,多个线程同时对 total 变量进行自增操作,这么做不会造成冲突吗?答案是不会的,因为加法是可交换的,即无论是 a + b 还是 b + a,结果都是相同的。Cython(通过 OpenMP)生成线程代码,每个线程计算循环子集的和,然后所有线程再将各自的和汇总在一起。

如果是交给 Numpy 来做的话,那么等价于如下:

np.sum(counts == val) / float(counts.size)

但是效率如何呢?我们来对比一下:

我们采用并行计算用的是 6.47 毫秒,Numpy 用的是 22 毫秒,看样子是我们赢了,并且 CPU 核心数越多,差距越明显,这便是并行计算的威力。当然对于这种算法来说,还是直接交给 Numpy 吧,毕竟人家都帮你封装好了,一个函数调用就可以解决了。

因此有效利用计算机硬件资源确实是最直接的办法。

并行编程的局限性

虽然 Cython 的 prange 容易使用,但其实还是有局限性的,当然这个局限性和 Cython无关,因为理想化的并行扩展本身就是一个难以实现的事情。我们举个栗子:

def filter(...):
    for i in range(nrows):
        for j in range(ncols):
            b[i, j] = (a[i, j] + a[i - 1, j] + a[i + 1, j] +
                       a[i, j - 1] + a[i, j + 1]) / 5.0)

假设我们要做一个过滤器,计算每一个点、加上它周围的四个点的平均值。但如果这里将外层的 range 换成 prange,那么它的整体性能不会得到提升。因为内层循环访问的是不连续的数组元素,由于缺乏数据本地性,CPU 的缓存无法生效,从而导致 prange 变慢。

那么我们什么时候使用 prange 呢?遵循以下法则即可:

  • 1. prange 能够很好的利用 CPU 并行操作, 这一点我们已经说过了
  • 2. 非本地读写的那些和内存绑定的操作很难提高速度
  • 3. 用较少的线程更容易实现加速, 因为对于 CPU 密集而言, 即便指定了超越核心数的线程也是没有意义的
  • 4. 使用优化的线程并行库是将 CPU 所有核心都用于常规的计算的最佳方式

当然,其实我们在开发的时候是可以随时使用 prange 的,只要循环体不和 Python 对象进行交互即可。

总结

Cython 允许我们绕过全局解释器锁,只要我们把和 Python 无关的代码分离出来即可。对于那些不需要和 Python 交互的 C 代码,可以轻松的使用 prange 实现基于线程的并行。

而在其它语言中,基于线程的并行很容易出错,并且难以正确处理。而 Cython 的 prange 则不需要我们在这方面费心,能够轻松地处理很多性能瓶颈。

posted @ 2020-07-12 21:10  古明地盆  阅读(6639)  评论(4编辑  收藏  举报