Featured image of post No GIL Python 的冒险

No GIL Python 的冒险

在前几周的时候,Python的允许禁用 GIL PR 正式合并进入了 Python 3.13 的master分支。这是一个非常重要的 PR,因为它在未来将会对 Python 的并发性能产生非常大的影响。在即将到来的 Python 3.13 中,这个允许禁用 GIL和包含了 Copy and Paste JIT 技术,这些同时都对 Python 的性能产生了非常大的影响。

什么是 GIL

GIL,即全局解释器锁,是 Python 语言中一个技术术语。官方实现的 CPython 中包含了 GIL 的实现,同时也是最广泛使用的实现。GIL 的主要目的是在任何时候只允许一个线程执行 Python 字节码,这意味着即使你的程序在多核处理器上运行,也无法实现真正的并行执行。GIL 的存在主要是为了简化 CPython 的内存管理。Python 的对象,如列表、字典等,不是线程安全的,这意味着如果多个线程同时从不同的核心修改同一个对象,可能会导致数据不一致或者程序崩溃。GIL 通过限制同时执行的线程数来避免这种情况。

换句话说:如果一个系统线程想要执行 Python 的字节码,必须要获取到 GIL 锁,然后才可以执行 Python 字节码。而如果没有获取到锁,那么线程就会休眠,直到获得信号而被唤醒。为了保证 Python 的效率,Python 也会自动切换线程,比如 IO 阻塞时,或者执行了指定数量的 Python 字节码时。这样就尽量保证了 Python 的效率。当然,你也可以选择手工释放 GIL 锁,比如使用 C/C++/Rust 扩展,或者使用 Cython 等开发高性能扩展时。

但是这个 GIL 也是 Python 的一个瓶颈,因为它限制了 Python 的并发性能。在多核处理器上,Python 的并发性能并不是很好。这也是为什么很多人选择使用多进程而不是多线程来提高 Python 的并发性能。在现代的计算机上,多核处理器已经是标配,因此 Python 的并发性能成为了一个非常重要的问题。这也是为什么这么多年来,Python 的 GIL 一直是一个非常热门的话题。

安装 Python 3.13a5

在这篇文章中,我们将会使用 Python 3.13a5 来演示如何禁用 GIL。你可以在这里下载 Python 3.13a5 的安装包。当然你也可以通过 pyenv 管理多版本的 Python 版本。下图就是演示如何使用 pyenv 安装 Python 3.13a5 的过程:

pyenv install cpython 3.13a5

注意这里的参数 --disable-gil,这个参数就是用来开启禁用 GIL 的功能选项。

测试 GIL 禁用效果

由于 GIL 主要限制了 Python 的并发性能,因此我们可以通过测试并发性能来测试 GIL 禁用的效果。这里我们使用了一个简单的多线程测试程序来测试 GIL 禁用的效果:

# gil_bench.py
import math
import time
from concurrent.futures import ThreadPoolExecutor


def get_primes(max: int) -> list[int]:
  if max < 2:
    raise ValueError()

  primes = [2]
  for n in range(3, max + 1):
    is_prime = True
    for i in range(2, int(math.sqrt(n)) + 1):
      if n % i == 0:
        is_prime = False
        break

    if is_prime:
      primes.append(n)

  return primes


if __name__ == "__main__":
  print("concurrency,time")
  for concurrency in range(1, 10 + 1):

    start = time.monotonic()

    with ThreadPoolExecutor(max_workers=concurrency) as executor:
      futures = [executor.submit(get_primes, 500000) for _ in range(concurrency)]

    for f in futures:
      f.result()

    end = time.monotonic()
    duration = end - start

    print(f"{concurrency},{duration:.2f}")

这个程序的主要功能是测试在不同的版本在线程池并发下,计算 500000 以内的素数所需要的时间。我们可以通过这个程序来测试 GIL 禁用的效果。

接下来我们可以尝试在不同版本的 Python 下执行这段代码的测试,作为对比,我们添加了多个 Python 版本进行对比:

测试环境为 macOS 14.4 + M1 Pro 处理器

$ > pyenv shell 3.10.13   # 使用 3.10.13 版本
$ > python gil_bench.py 
concurrency,time
1,0.95
2,1.90
3,2.85
4,3.81
5,4.77
6,5.73
7,6.67
8,7.64
9,8.59
10,9.55
$ > pyenv shell 3.12.2  # 使用 3.12.2 版本
$ > python gil_bench.py
concurrency,time
1,0.88
2,1.76
3,2.64
4,3.54
5,4.44
6,5.32
7,6.21
8,7.08
9,7.98
10,8.86
$ > pyenv shell 3.13.0a5  # 使用 3.13.0a5 版本,这个版本使用了 `--disable-gil` 参数编译
$ > python gil_bench.py 
concurrency,time
1,1.01
2,2.03
3,3.04
4,4.07
5,5.11
6,6.13
7,7.17
8,8.19
9,9.17
10,10.22
$ > python -X gil=0 gil_bench.py  # 使用 3.13.0a5 版本,这个版本使用了 `-X gil=0` 参数运行,这会禁用 GIL
concurrency,time
1,1.01
2,1.04
3,1.25
4,1.57
5,1.61
6,1.81
7,2.28
8,2.39
9,2.75
10,2.89

这个结果看上去还是有点乱,让我们整理成为表格:

并发数Python 3.10.13Python 3.12.2Python 3.13.0a5 (–disable-gil)Python 3.13.0a5 (–disable-gil) -X gil=0
10.950.881.011.01
21.901.762.031.04
32.852.643.041.25
43.813.544.071.57
54.774.445.111.61
65.735.326.131.81
76.676.217.172.28
87.647.088.192.39
98.597.989.172.75
109.558.8610.222.89

从这个表格中我们可以看到,Python 3.13.0a5 在使用 --disable-gil 参数编译后,性能对比之前的版本有了比较明显的性能退步。这个原因是因为为了禁用 GIL,Python 引入了一些额外的锁开销,这导致了性能的下降。但是当我们使用 -X gil=0 参数来运行 Python 3.13.0a5 时,性能有了非常明显的提升,大概有 3 倍的性能提升。这说明 Python 3.13.0a5 的 GIL 禁用功能后并发性能上有了非常大的提升。当然,如果你不需要禁用 gil 的特性,在编译时一定不需要使用 --disable-gil,否则会有比较明显的性能下降。因为 Python 在每个版本种都会做很多性能优化,在使用新版本时,不禁用 GIL 的情况下,性能也会对比之前有略微的提升。

总结

在这篇文章中,我们通过一个简单的多线程测试程序来测试 GIL 禁用的效果。通过对比,我们发现 Python 3.13.0a5 在启用 GIL 禁用编译参数后,较多线程运行的场景下,使用 -X gil=0 参数运行时,性能会出现较大提升。但是在单线程处理上由于为了保证出去 GIL 的安全性不得不做了很多妥协。因此在实际使用时,仍旧需要根据自身的使用场景,根据实际情况来选择是否禁用 GIL。

Built with Hugo
主题 StackJimmy 设计