为什么会有 GIL?如何释放 GIL 实现并行?

发布时间 2023-08-24 13:49:09作者: tomato-haha
https://mp.weixin.qq.com/s?__biz=Mzg3NTczMDU2Mg==&mid=2247503319&idx=1&sn=7dd1c7c05ccb319501eb0457a1f4c9b7&chksm=cf3f8e3af848072c0ef585787bf2b4359f3d63e43a927f93eaef81c407efff73233ffc00a2c7&scene=178&cur_album_id=2513963764346486784#rd
图片

楔子

 

图片

 

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

但以上的这些改进都是基于单线程的,这一次我们来学习 Cython 的多线程特性,以及如何在 Cython 中释放 GIL 实现并行执行。

并且 Cython 还提供了一个 prange 函数,它可以轻松地将普通的 for 循环转成使用多个线程的循环,接入所有可用的 CPU 核心。使用的时候我们会看到,平常令人尴尬的 CPU 并行操作,通过 prange 会有很好的表现。

不过在介绍 prange 之前,我们必须要先了解 Python 的运行时(runtime)和本机线程的交互,以及全局解释器锁(GIL)。

 

图片

线程并行和全局解释器锁

 

图片

 

如果讨论基于线程的并行,那么全局解释器锁(GIL)是一个绕不开的话题。我们知道 GIL 是一个施加在解释器之上的互斥锁,用于防止本机多个线程同时执行字节码。

换句话说 ,GIL 确保解释器在程序执行期间,同一时刻只会使用操作系统的一个线程。不管你的 CPU 是多少核,以及你开了多少个线程,但是同一时刻只会使用操作系统的一个线程、去调度一个 CPU。而且 GIL 不仅影响 Python 代码,也会影响 Python/C API。

首先我们来分析一下为什么会有 GIL 这个东西存在?举个例子:

import dis

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

当使用 del 删除一个变量的时候,对应的指令是 DELETE_NAME,这条指令做的事情非常简单:通过宏 Py_DECREF 将对象的引用计数减 1,并且判断减少之后其引用计数是否为 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,从而执行了对象的销毁动作(tp_dealloc),内存被释放。

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

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

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

所以 GIL 才被称之为是字节码级别的互斥锁,它保护每条字节码指令只有在执行完毕之后才会发生线程调度。

回到上面那个 del obj 例子当中,由于引入了 GIL,所以就不存在我们之前说的:在 A 将引用计数减一之后,挂起 A、唤醒 B这一过程。因为A已经开始了 DELETE_NAME 这条指令的执行,而在没执行完之前是不会发生线程调度的,所以此时不会出现悬空指针的问题。

 

因此 Python 的一条字节码指令会对应多行 C 代码,这其中可能会涉及很多个 C 函数的调用,我们举个例子:

 

图片

这是 FOR_ITER 指令,Python 的 for 循环对应的就是这条指令。可以看到里面的逻辑非常多,当然也涉及了多个函数调用,而且函数内部又会调用其它的函数。如果没有 GIL,那么这些逻辑在执行的时候,任何一处都可能被打断,发生线程调度。

但是有了 GIL 就不同了,它是施加在字节码层面上的互斥锁,保证每次只有一个线程执行字节码指令。并且不允许指令执行到一半时发生调度,因此 GIL 就保证了每条指令内部的 C 逻辑整体都是原子的。

而如果没有 GIL,那么即使是简单的引用计数,在计算上都有可能出问题。事实上,GIL 最初的目的就是为了解决引用计数的安全性问题。

 

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

 

图片

GIL 有没有可能被移除


 

图片

 

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

 

关于能否移除 GIL,就我本人来看不太可能(针对 CPython),这都几十年了,能移除早就移除了。

 

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

 

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

 

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

 

另外还有一个关键,就是当 GIL 被移除之后,会使得扩展模块的编写难度大大增加。因为 GIL 保护的不仅仅是 Python 解释器,还有 Python/C API。像很多现有的 C 扩展,在很大程度上都依赖 GIL 提供的解决方案,如果要移除 GIL,就需要重新解决这些库的线程安全性问题。

 

比如我们熟知的 numpy,numpy 的速度之所以这么快,就是因为底层是 C 写的,然后封装成 Python 的扩展模块。而其它的库,像 pandas、scipy、sklearn 都是在 numpy 之上开发的,如果把GIL移除了,那么这些库就都不能用了。

 

还有深度学习,像 tensorflow、pytorch 等框架所使用的底层算法也都不是Python编写的,而是 C 和 C++,Python 只是起到了一个包装器的作用。Python在深度学习领域很火,主要是它可以和 C 无缝结合,如果 GIL 被移除,那么这些框架也没法用了。

 

因此在 2022 年的今天,生态如此成熟的 Python,几乎是不可能摆脱 GIL 了。否则这些知名的科学计算相关的库就要重新洗牌了,可想而知这是一个什么样的工作量。

 

小插曲:我们说去掉GIL的老铁有两位,分别是Greg Stein和Mark Hammond,这个Mark Hammond估计很多人都见过。

 

图片

特别感谢 Mark Hammond,没有它这些年无偿分享的Windows专业技术,那么Python如今仍会运行在DOS上。

 

图片

图解 GIL


 

图片

 

Python 启动一个线程,底层会启动一个 C 线程,最终启动一个操作系统的线程。所以还是那句话,Python 的线程实际上是封装了 C 的线程,进而封装了 OS 线程,一个 Python 线程对应一个 OS 线程。

 

实际执行的肯定是 OS 线程,而 OS 线程 Python 解释器是没有权限控制的,它能控制的只是 Python 的线程。假设有 4 个 Python 线程,那么肯定对应 4 个 OS 线程,但是解释器每次只让一个 Python 线程调用 OS 线程去执行,其它的线程只能干等着,只有当前的 Python 线程将 GIL 释放了,其它的某个线程在拿到 GIL 时,才可以调用相应的 OS 线程去执行。

 

总结一下就是,没有拿到 GIL 的 Python 线程,对应的 OS 线程会处于休眠状态;拿到 GIL 的 Python 线程,对应的 OS 线程会从休眠状态被唤醒。

 

图片

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

再次强调:GIL 并不是 Python 语言的特性,它是 CPython 开发人员为了方便内存管理才加上去的。只不过解释器我们大部分用的都是 CPython,所以很多人认为 GIL 是 Python 语言本身的一个特性,但其实不是的。

Python 是一门语言,而 CPython 是对使用 Python 语言编写的源代码进行解释执行的一个解释器。而解释器不止 CPython 一种,还有 JPython,JPython 就没有GIL。因此 Python 语言本身是和 GIL 无关的,只不过我们平时在说 Python 的 GIL 的时候,指的都是 CPython 里面的 GIL,这一点要注意。

图片

所以就类似于上图,一个线程执行一会儿,另一个线程执行一会儿,至于线程怎么切换、什么时候切换,我们后面会说。

 

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

 

那么问题来了,有了 GIL,在编写多线程代码的时候是不是就意味着不需要加锁了呢?

 

答案显然不是的,因为 GIL 保护的是每条字节码不会被打断,而很多代码都是一行对应多条字节码,所以每行代码是可以被打断的。比如:a = a + 1 这样一条语句,它对应4条字节码:LOAD_NAME, LOAD_CONST, BINARY_ADD, STORE_NAME。

 

假设此时 a = 8,两个线程同时执行 a = a + 1,线程 A 执行的时候已经将 a 和 1 压入运行时栈,栈里面的 a 指向的是 8。但还没有执行 BINARY_ADD 的时候,发生线程切换,轮到线程 B 执行,此时 B 得到的 a 显然还是指向 8,因为线程 A 还没有对变量 a 做加法操作。然后 B 比较幸运,它一次性将这 4 条字节码全部执行完了,所以 a 应该指向 9。

 

然后线程调度再切换回 A,此时会执行 BINARY_ADD,不过注意:栈里面的 a 目前指向的还是 8,所以加完之后还是 9。

 

因此本来 a 应该指向10,但是却指向 9,就是因为在执行的时候发生了线程调度。所以我们在编写多线程代码的时候还是需要加锁的,GIL 只是保证每条字节码执行的时候不会被打断,但是一行代码往往对应多条字节码,所以我们会通过 threading.Lock() 再加上一把锁。这样即便发生了线程调度,但由于我们在 Python 的层面上又加了一把锁,别的线程依旧无法执行,这样就保证了数据的安全。

 

 

图片

GIL 何时被释放


 

图片

 

那么问题来了,GIL 啥时候会被释放呢?关于这一点,Python 有一个自己的调度机制:

 

  • 1)当遇见 io 阻塞的时候会释放,因为 io 阻塞是不耗费 CPU 的,所以此时虚拟机会把该线程的锁释放;

  • 2)即便是耗费 CPU 的运算,也不会一直执行,会在执行一小段时间之后释放锁,为了保证其他线程都有机会执行,就类似于 CPU 时间片轮转的方式;

 

调度机制虽然简单,但是这背后还隐藏着两个问题:

 

  • 在何时挂起线程,选择处于等待状态的下一个线程?

  • 在众多处于等待状态的候选线程中,选择激活哪一个线程?

 

在Python的多线程机制中,这两个问题分别是由不同的层次解决的。对于何时进行线程调度问题,是由 Python 自身决定的。考虑一下操作系统是如何进行进程切换的,当一个进程运行了一段时间之后,发生了时钟中断,操作系统响应时钟,并开始进行进程的调度。

 

同样,Python也是模拟了这样的时钟中断,来激活线程的调度。我们知道字节码的执行原理就是按照指令的顺序一条一条执行,而解释器内部维护着一个数值,这个数值就是 Python 内部的时钟。在 Python2 中如果一个线程执行的字节码指令数达到了这个值,那么会进行线程切换,并且这个值在 Python3 中仍然存在。

import sys
# 默认执行 100 条字节码之后
# 启动线程调度机制,进行切换
print(sys.getcheckinterval())  # 100

# 但是在 Python3 中,改成了时间间隔
# 表示一个线程在执行 0.005s 之后进行切换
print(sys.getswitchinterval())  # 0.005

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

sys.getcheckinterval 和 sys.setcheckinterval 在 Python3.8 的时候已经废弃了,因为线程发生调度不再取决于执行的字节码条数,而是时间间隔。

 

除了执行时间之外,还有就是我们之前说的遇见 IO 阻塞的时候会进行切换,所以多线程在 IO 密集型的场景下还是很有用处的。说实话如果 IO 都不会自动切换的话,那么 Python 的多线程才是真的没有用。

 

然后一个问题就是,Python 在切换的时候会从等待的线程中选择哪一个呢?很简单,Python 是借用了底层操作系统所提供的调度机制来决定下一个进入 Python 解释器的线程究竟是谁。

 

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

  • GIL 对于 Python 对象的内存管理来说是不可或缺的;

  • GIL 和 Python 语言本身没有什么关系, 它只是 CPython 为了方便管理内存所引入的一个实现,只不过 CPython 是使用最为广泛的一种 Python 解释器,我们默认指的就是它。但是别的 Python 解释器则不一定需要 GIL,比如 JPython;

 

到目前为止我们介绍了很多关于 GIL 的内容,主要是为了解释 GIL 到底是个什么东西(底层就是一个结构体实例),以及为什么要有 GIL。然后重点来了,我们能不能手动释放 GIL 呢?

在 Python 里面不可以,但在 Cython 里面是可以的。因为 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 的数据结构。

而为了能够释放 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 编译器并不能保证它可以百分百做到精确捕捉每一个这样的错误(事实上除非你是刻意不想让编译器捕捉,否则的话都能捕捉到),因此在编写 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。

 

图片

with 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 声明的函数
        # 并且函数内部都是纯 C 操作、不能涉及 Python,否则是编译不过去的
        # 但如果定义的函数不使用 nogil 声明,那么即使内部不涉及 Python
        # 也不可以在 with nogil: 上下文中调用
        # 而这里的 func 是一个 nogil 函数,因此它可以在此处被调用
        res = func(a, b)
        
    # with 上下文结束之后,会再度获取 GIL
    return res 

文件名为 cython_test.pyx,我们编译测试一下:

import pyximport
pyximport.install(language_level=3)

import cython_test
print(cython_test.add(12.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 不允许出现在 with nogil 上下文管理器中。

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

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

# 我们也可以在全局使用 with nogil
cdef 
int res
with nogil:
    res = func(2233)
print(res)  # 55

所以 with nogil 上下文管理器的一个用途是在阻塞操作期间释放GIL,从而允许其它 Python 线程执行另一个代价昂贵的操作。

另外,还记得前面说的异常传递的问题吗?如果返回值是 C 的类型,那么函数中出现异常的时候不会向上抛,而是会把异常忽略掉。至于解决办法也很简单,通过 except ? 指定一个哨兵即可,然后该特性是可以和 nogil 结合的。

# except ? -1 要写在 nogil 的后面
cpdef 
double func(int a, int b) nogil except ? -1
    return a / b

编译测试一下:

import pyximport
pyximport.install(language_level=3)

import cython_test
cython_test.func(10)
"""
Traceback (most recent call last):
  File "D:/satori/main.py", line 5, in <module>
    cython_test.func(1, 0)
  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
    # 因此该函数本来不可以声明为 nogil
    # 但可以通过 with gil 上下文,让其获取 GIL
    # 所以一个 nogil 函数,如果里面出现了 Python/C API
    # 那么应该放在 with gil 上下文中,否则的话,编译报错
    cdef 
int res = a + b;
    with 
gil:
        print("-------")
    return res

cdef 
int res
with 
nogil:
    res = func(1122)
    # 同理在 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(1122)  # 并行操作
    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 计算时,才建议这么做。

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

 

图片

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


 

图片

 

首先必须要澄清一点,GIL 只有在多线程的情况下才会出现,如果是单线程,那么 CPython 是不会创建 GIL 的。而一旦我们启动了多线程,那么 GIL 就被创建了。

线程如果想安全地访问 Python 对象,就必须要持有全局解释器锁(GIL),如果没有这个锁,那么多线程基本上算是废了,即便是最简单的操作都有可能发生问题。例如两个线程同时引用了一个对象,那么这个对象的引用计数应该增加 2,但可能出现只增加 1 的情况。

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

而 Python 为了维护 OS 线程执行的状态信息,提供了一个线程状态对象:PyThreadState。虽然真正用来执行的线程以及状态肯定是由操作系统进行维护的,但虚拟机在运行的时候总需要其它的一些与线程相关的状态和信息,比如:是否发生了异常等等,这些信息显然操作系统没有办法提供。

所以 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_UNBLOCK_THREADS、Py_BLOCK_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 代码翻译成 C 代码。而翻译成 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 文件不是一件容易的事,因为编译器要考虑很多很多事情,比如兼容不同系统的编译器后端以及 ABI。所以翻译之后的 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=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 之后,它就不再受解释器的制约了,因为它绕过了解释执行这一步。只有当自己再主动获取 GIL 之后,才会回到正常的 GIL 调度中来。

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

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

而 Cython 提供了一个 prange 函数,可以非常方便地实现,只不过为了引出它做了大量的准备工作,但这一切都是值得的。那么下一篇文章,我们就来聊聊 prange。