解除 Python GIL 锁,让代码快 300 倍
什么是 GIL
Python 是一门解释型语言,不同于其他编译型语言需要编译成可执行文件后执行,Python 使用解释器来解析 Python 语句。Python 解释器的实现有很多,例如用 C 实现的 CPython、Java 实现的 Jython、Python 实现的 PyPy。其中使用最广泛的是 CPython。
由于用 C 实现一个 Python 解释器需要处理复杂的线程安全和并发环境的内存管理问题,为了降低解释器的实现复杂度,CPython 中引入了 GIL(Global Interpreter Lock)。
在使用多线程时,CPython 解释器会创建 GIL,每个线程在执行前需要先获取 GIL,阻止其他线程的执行。正在运行中的 Python 线程在以下情况下会释放自己占有的 GIL:
-
抢占机制:解释器每隔一段时间检查 GIL 被占用的时间,如果超过了某个阈值,就会强制线程释放 GIL。时间间隔可以用
sys.setswitchinterval()
设置,或者用sys.getswitchinterval()
查看。Python 3.8 以前还可以用
sys.setcheckinterval()
设置执行多少条字节码后强制线程释放 GIL,但是 3.8 之后这种方法被废弃了 -
Python 线程等待 I/O 时会主动释放 GIL
通过这种机制,Python 在单核的情况下仍然能够充分利用 CPU 时间片,轮流运行所有的线程。
GIL 的存在是为了方便 CPython 解释器的实现,保证了一个字节码在执行过程中不会被打断,但并不能保证 Python 程序是线程安全的。
例如对于下面这个函数 +=
从代码层面来看是一条语句,但是用 dis
翻译成字节码后被分成了 INPLACE_ADD
和 STORE_FAST
两条语句。由于上面的抢占机制的存在,在执行完 INPLACE_ADD
之后解释器可能会去执行别的线程,如果此时别的线程内也修改了 n 的值,就会出现并发安全问题。
>>> def add_one(n):
... n += 1
...
>>> import dis
>>> dis.dis(add_one)
2 0 LOAD_FAST 0 (n)
2 LOAD_CONST 1 (1)
4 INPLACE_ADD
6 STORE_FAST 0 (n)
8 LOAD_CONST 0 (None)
10 RETURN_VALUE
GIL 的存在导致一个 CPython 解释器同时只能执行一个线程,无法利用多核的能力,解决方法通常有以下几种:
- 使用 Jython、PyPy 等其他解释器实现
- 使用多进程代替多线程,这样每个进程可以并行执行
- 使用 C 等其他语言实现存在性能瓶颈的代码
什么是 Cython
前面提到,想要解决 GIL 带来的性能瓶颈,可以使用 C 实现关键代码,Cython 就提供了 C 和 Python 的集成能力。Cython 可以看成是 Python 的超集,代码文件使用 .pyx
后缀。它融合了 Python 的语法风格和 C 的静态类型,写好之后可以编译成库文件,这样在 Python 中可以像对待其他 Python 库一样,直接使用 import
导入 Cython 编写的库。
比如对于一个计算斐波那契数列的程序,用 Python 的实现十分简洁,代码如下:
# fib.py
def fib(n):
a, b = 0.0, 1.0
for i in range(n):
a, b = a + b, a
return a
用 C 的实现使用静态类型,编译之后执行可以获得远高于 Python 的运行效率,代码如下:
// fib.c
double cfib(int n) {
int i;
double a=0.0, b=1.0, tmp;
for (i=0; i<n; ++i) {
tmp = a; a = a + b; b = tmp;
}
return a;
}
而 Cython 的实现则能兼顾开发和执行效率,代码如下:
# fib.pyx
def fib(int n):
cdef int i
cdef double a=0.0, b=1.0
for i in range(n):
a, b = a + b, a
return a
接下来将 fib.pyx
打包为共享库,这个过程分为两步:
- 使用 Cython 编译器将 Cython 代码优化后编译成 C 代码
- 将 C 代码编译成共享库
这两步可以用一个 [setup.py](http://setup.py)
脚本实现:
# setup.py
from distutils.core import setup
from Cython.Build import cythonize
setup(ext_modules=cythonize('fib.pyx'))
然后执行 python [setup.py](http://setup.py) bdist_ext -i
就能得到共享库,例如 MacOS 下使用 Python3.6 执行这段脚本会输出一个 fib.cpython-36m-darwin.so
文件,这样在其他 Python 代码中就能通过 import fib
将 Cython 实现的 fib 函数作为模块导入了。
Cython 效率如何
通过对上面 Python、C、Cython 三种斐波那契数列计算实现的性能测试,得出 C 实现的执行速度大概是 Python 实现的 80 倍,而 Cython 实现的执行速度大概是 Python 实现的 50 倍。可以看出 Cython 和 C 相比虽然还有一些差距,但是执行效率仍然远超 Python。
Cython 能够提高执行效率的原因主要有以下几点:
- **减少函数调用开销:**Cython 能够生成高度优化的 C 代码,绕过了耗时的 Python/C API 调用
- **减少循环开销:**Python 的循环执行效率远低于其他的编译语言,用 Cython 进行编译后降低了循环的耗时
- **加快数学运算:**Python 的类型是动态的,因此在执行过程中,需要先判断运算参数的类型,然后找到对应的魔法方法(例如执行
+
操作时会调用参数的__add__
方法),这个解析的过程需要消耗很多时间。而 Cython 中指定了参数的类型,只要一条机器指令就能完成运算操作 - **内存管理:**Python 执行过程中生成的对象都分配在堆中,需要复杂的内存管理机制,在创建和释放对象的过程中会产生一定的开销。而 Cython 执行函数的过程中大多数对象都分配在栈中,比在堆上分配对象快。
用 Cython 操作 GIL
Cython 不仅能将使用 Python 风格编写的代码编译成 C,还将解释器中控制 GIL 的接口暴露了出来,可以通过 nogil
函数属性和 with nogil
上下文管理器来使用。
nogil 函数属性
通过在定义函数时指定 nogil
属性声明这个函数可以在 GIL 释放的环境中运行:
cdef int func(int a, double b) nogil:
pass
使用 nogil
修饰的函数必须要通过 Cython 中的 cdef
或者 cpdef
关键字定义, cdef
表示定义的是一个 C 函数,必须显式定义函数的参数和返回值得类型。 cpdef
表示同时定义了一个 C 函数和一个 Python 函数。
Cython 函数的定义参考:notes-on-cython.readthedocs.io/en/latest/f…
GIL 是为了保护 Python 对象的内存管理设置的,因此在 GIL 释放之后不能和 Python 对象发生交互,所以使用 def
定义的函数不能被 nogil
修饰,在 nogil
修饰的函数中也不能声明 Python
对象。
with nogil 上下文管理器
使用 with nogil
可以创建一个没有 GIL 的上下文环境,在这个环境中可以执行上面定义的 nogil
函数,例如:
with nogil:
func(1, 2.0)
with nogil
必须在存在 GIL 的环境中调用,例如下面的代码会出错:
with nogil:
with nogil:
pass
如果需要重新获得 GIL,可以使用 with gil
:
with nogil:
# ...
with gil:
# ...
使用并行计算斐波那契数列
下面分别使用 Python 和释放 GIL 的 Cython 实现斐波那契数列的计算,做一下效率的对比。启动 6 个线程,分别计算 fib(35)
的值,然后用 timeit
统计事件
Python版本
import timeit
from threading import Thread
def fib(n):
if n <= 2:
return 1
else:
return fib(n - 1) + fib(n - 2)
def main():
threads = [Thread(target=fib, args=(35,)) for i in range(6)]
for t in threads:
t.start()
for t in threads:
t.join()
print(timeit.timeit(main, number=1))
运行脚本:
$ python fib.py
12.006467046999887
运行时间大概是 12 秒
Cython 不释放 GIL 版本
首先定义计算斐波那契数列的函数 fib()
,然后暴露一个调用函数 func()
,并调用计算函数
# fib.pyx
cdef double fib(int n):
if n <= 2:
return 1.0
else:
return fib(n - 1) + fib(n - 2)
def func():
res = fib(35)
return res
然后编写 [setup.py](http://setup.py)
并将 Cython 代码编译成 so 文件
# setup.py
from distutils.core import setup
from Cython.Build import cythonize
setup(ext_modules=cythonize("fib.pyx"))
执行编译并运行代码
$ python setup.py build_ext -i
...
$ python main.py
0.1717213299998548
通过结果可以看出,即使没有释放 GIL,仅仅通过 Cython 的优化就已经使计算过程快了 70 倍
Cython 释放 GIL 的版本
调整一下 fib.pyx
中的定义,使用 nogil
修饰 fib
函数,然后在 func()
中释放 GIL:
# fib.pyx
cdef double fib(int n) nogil:
if n <= 2:
return 1.0
else:
return fib(n - 1) + fib(n - 2)
def func():
with nogil:
res = fib(35)
return res
[setup.py](http://setup.py)
保持不变,重新编译后执行:
$ python setup.py build_ext -i
...
$ python main.py
0.03565474399965751
释放 GIL 后执行时间又缩短到了原来的 1/5,其执行速度大概是 Python 版本 350 倍。6 个线程基本都充分利用了计算能力。
总结
根据前面的内容,可以发现,Cython 主要能通过以下两个方面优化 CPython 解释器执行代码的速度:
- 将解释型的 Python 优化并编译成 C 的代码,虽然比不上纯 C 代码的执行效率,但是相比于 Python 已经进步了不少
- 在多线程模式下释放 GIL,充分利用 CPU 的计算能了
不过需要注意的是,Cython 的优化能力主要适用于 CPU 密集型的任务。而对于 I/O 密集型的任务,大部分的状况是 CPU 在等硬盘、内存、网络设备的读写操作,能够使用 Cython 优化的部分就十分有限。
参考
转载自:https://juejin.cn/post/7007043202251390984