解除 Python GIL 锁,让代码快 300 倍

4,056 阅读5分钟

什么是 GIL

Python 是一门解释型语言,不同于其他编译型语言需要编译成可执行文件后执行,Python 使用解释器来解析 Python 语句。Python 解释器的实现有很多,例如用 C 实现的 CPython、Java 实现的 Jython、Python 实现的 PyPy。其中使用最广泛的是 CPython。

由于用 C 实现一个 Python 解释器需要处理复杂的线程安全和并发环境的内存管理问题,为了降低解释器的实现复杂度,CPython 中引入了 GIL(Global Interpreter Lock)。

在使用多线程时,CPython 解释器会创建 GIL,每个线程在执行前需要先获取 GIL,阻止其他线程的执行。正在运行中的 Python 线程在以下情况下会释放自己占有的 GIL:

  1. 抢占机制:解释器每隔一段时间检查 GIL 被占用的时间,如果超过了某个阈值,就会强制线程释放 GIL。时间间隔可以用 sys.setswitchinterval() 设置,或者用 sys.getswitchinterval() 查看。

    Python 3.8 以前还可以用 sys.setcheckinterval() 设置执行多少条字节码后强制线程释放 GIL,但是 3.8 之后这种方法被废弃了

  2. Python 线程等待 I/O 时会主动释放 GIL

通过这种机制,Python 在单核的情况下仍然能够充分利用 CPU 时间片,轮流运行所有的线程。

GIL 的存在是为了方便 CPython 解释器的实现,保证了一个字节码在执行过程中不会被打断,但并不能保证 Python 程序是线程安全的。

例如对于下面这个函数 += 从代码层面来看是一条语句,但是用 dis 翻译成字节码后被分成了 INPLACE_ADDSTORE_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 解释器同时只能执行一个线程,无法利用多核的能力,解决方法通常有以下几种:

  1. 使用 Jython、PyPy 等其他解释器实现
  2. 使用多进程代替多线程,这样每个进程可以并行执行
  3. 使用 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 打包为共享库,这个过程分为两步:

  1. 使用 Cython 编译器将 Cython 代码优化后编译成 C 代码
  2. 将 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 能够提高执行效率的原因主要有以下几点:

  1. **减少函数调用开销:**Cython 能够生成高度优化的 C 代码,绕过了耗时的 Python/C API 调用
  2. **减少循环开销:**Python 的循环执行效率远低于其他的编译语言,用 Cython 进行编译后降低了循环的耗时
  3. **加快数学运算:**Python 的类型是动态的,因此在执行过程中,需要先判断运算参数的类型,然后找到对应的魔法方法(例如执行 + 操作时会调用参数的 __add__ 方法),这个解析的过程需要消耗很多时间。而 Cython 中指定了参数的类型,只要一条机器指令就能完成运算操作
  4. **内存管理:**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 解释器执行代码的速度:

  1. 将解释型的 Python 优化并编译成 C 的代码,虽然比不上纯 C 代码的执行效率,但是相比于 Python 已经进步了不少
  2. 在多线程模式下释放 GIL,充分利用 CPU 的计算能了

不过需要注意的是,Cython 的优化能力主要适用于 CPU 密集型的任务。而对于 I/O 密集型的任务,大部分的状况是 CPU 在等硬盘、内存、网络设备的读写操作,能够使用 Cython 优化的部分就十分有限。

参考