什么是全局解释器锁 (GIL)?
自20世纪80年代末,Guido Van Rossum在荷兰阿姆斯特丹东部的一个科技园区开始开发Python编程语言,它最初被设计为一种单线程的解释型语言。这到底是什么意思呢?
你可能会听说,编程语言分为解释型和编译型两种。那么Python属于哪一类?答案是:它属于两者。
实际上,你很少遇到完全由解释器直接从源代码进行解释的编程语言。对于解释型语言而言,可读的源代码通常会被转换成一种中间形式,也就是字节码。然后,解释器会逐条读取这些字节码并执行指令。
在这里,“解释器”通常被称作“虚拟机”,尤其是在Java等其他语言中,它们和Python一样,都是通过虚拟机来处理字节码。在Java等语言中,更常见的做法是直接分发编译后的字节码,而Python应用则通常以源代码的形式分发(尽管现在,包也经常以wheel和sdist的形式部署)。
这种意义上的虚拟机在许多意想不到的地方都会出现,比如在PostScript格式中(PDF文件本质上就是编译后的PostScript),以及在字体渲染过程中。
如果你在自己的Python项目中看到过很多*.pyc文件,这些就是你的应用程序编译后的字节码。你可以像探索Java类文件一样,对这些pyc文件进行反编译和研究。
当们执行Python程序时,Python的执行程序会生成一系列指令流,即字节码,然后解释器会逐一读取并执行这些指令。
如果你启动了多个线程,它们将共享相同的内存空间(局部变量除外),因此它们都能访问和修改相同的数据对象。每个线程都有自己独立的栈和指令指针来执行字节码。
如果多个线程同时尝试访问或修改同一个对象,比如一个线程在向字典中添加内容,而另一个线程在读取字典,这时你有两个选择:
- 让字典和其他所有对象都实现线程安全,这需要付出很多努力,并且对于单线程应用来说会降低效率;
- 创建一个全局互斥锁(通常称为mutex),这样任何时候都只有一个线程能够执行字节码。
后者就是们所说的全局解释器锁(GIL)。而前者是Python开发者提到的“自由线程”模式。
另外值得一提的是,GIL简化了垃圾回收的过程,使其变得更快速。由于垃圾回收本身是一个复杂的话题,们在这里不深入讨论,但简单来说,Python会跟踪一个对象被引用的次数,当引用计数降到零时,Python就知道可以安全地删除这个对象了。如果多个线程同时创建和删除对不同对象的引用,可能会导致竞态条件和内存损坏,因此任何自由线程的版本都需要为所有对象使用原子计数的引用。
GIL还简化了Python的C扩展开发(例如使用Cython),因为你可以在开发时做一些关于线程安全的假设,这会让你的工作更加轻松。
为什么会有全局解释器锁?
尽管Python近年来变得非常流行,但它并不是一门新语言——它在20世纪80年代末诞生,首次发布于1991年2月20日(比稍微早一点)。在那个时代,计算机的形态与现在大相径庭。大多数程序都是单线程的,单个CPU核心的性能正以摩尔定律所描述的那样呈指数级增长。在那种环境下,对于大多数不使用多核的程序来说,牺牲单线程性能以换取线程安全是没有必要的。
而且,实现线程安全显然需要付出很多努力。
这并不是说你不能在Python中利用多核CPU。只是说,你不能通过线程来实现,而必须通过多个进程(例如使用multiprocessing模块)。
多进程与多线程的主要区别在于,每个进程都有自己的Python解释器和独立的内存空间。这意味着多个进程无法访问内存中的相同对象,而必须使用特殊的机制和通信方式来共享数据(可以参考“在进程间共享状态”和multiprocessing.Queue)。
值得一提的是,与多线程相比,使用多进程会有更大的开销,而且在数据共享上也更加困难。
然而,使用多线程并不总是像人们通常认为的那样糟糕。如果Python正在进行I/O操作,比如读取文件或发起网络请求,它会释放GIL,让其他线程得以运行。这意味着,如果你的程序主要是I/O密集型的,那么多线程通常会和多进程一样高效。只有在你的程序是CPU密集型的,GIL才会成为一个显著的问题。
为什么想要去掉全局解释器锁?
虽然移除GIL的呼声已经存在多年,但主要阻碍并非因为工程量大,而是因为这可能会让单线程程序的性能受损。
如今,电脑的单个CPU核心的性能提升速度已经放缓(尽管像苹果硅芯片这样的定制处理器架构取得了巨大进步),而核心数却在不断增加。这就意味着程序越来越需要利用多核的优势,而Python在多线程利用上的不足也日益凸显。
到了2021年,Sam Gross开发了一个无GIL的原型,这激发了Python决策委员会提出并投票通过了PEP 703——将CPython中的GIL变为可选。投票结果是肯定的,决策委员会决定接受这个提案,并计划分三个阶段逐步实施:
第一阶段:自由线程模式作为一个实验性功能,不是默认启用的。 第二阶段:自由线程模式得到官方支持,但不是默认选项。 第三阶段:自由线程模式成为默认设置。 从讨论中们可以看出,大家普遍不希望Python分裂成两个版本——一个有GIL,一个没有——而是希望在自由线程模式成为默认后,GIL最终被完全移除,只留下自由线程模式。
在关于GIL的讨论进行的同时,还有一个名为“更快的CPython”的项目在并行推进。这个项目得到了微软的资助,由Mark Shannon和Python之父Guido van Rossum领导。
这个团队的努力已经取得了显著的成果,尤其是在Python 3.11版本中,性能相比3.10有了大幅提升。
有了社区和决策委员会的支持,多核处理器的普及,以及“更快的CPython”项目的成功,现在正是开始实施GIL可选化计划的第一阶段的好时机。
即时编译器(JIT,Just-in-Time)
在这个Python新版本中,除了全局解释器锁(GIL)的重大变革外,还引入了一个实验性的即时编译器(JIT)。
即时编译器(JIT)是一种编译技术,它在执行前即时生成机器代码,与传统的提前编译(AOT)方式,如C语言的gcc或clang编译器不同。
之前们已经讨论过字节码和解释器。关键点是,在Python 3.13之前,解释器会逐条处理字节码,将其转换成机器代码后再执行。而现在,有了JIT编译器,字节码可以一次性转换成机器代码,并在需要时更新,不必每次都重新解释。
需要强调的是,Python 3.13中引入的这种即时编译器是一种“复制和修补”类型的JIT。这是一种2021年新提出的概念,其核心思想是使用一系列预设的模板——JIT编译器会寻找匹配这些模板的字节码,并用预设的机器代码进行替换。
传统的JIT编译器在功能上要强大得多,同时对内存的需求也更大,尤其是与Java或C#这类重度使用JIT编译的语言相比。(这也是Java程序占用较多内存的原因之一。)
JIT编译器的美妙之处在于它们可以在代码运行过程中进行适应。例如,JIT编译器会追踪代码的“热度”,并根据代码的执行频率进行增量优化,甚至可以根据程序的实际运行情况来指导优化过程(类似于AOT编译器中的性能分析优化)。这意味着JIT编译器不会在只运行一次的代码上浪费时间优化,而是对频繁执行的代码部分进行重点优化。
目前,Python 3.13中的JIT编译器还比较简单,不会进行过于复杂的操作,但它为Python性能的未来发展带来了极大的期待。
JIT编译器将带来哪些改变?
在短期内,JIT编译器的加入可能不会影响你编写或执行Python代码的方式。但它是Python解释器内部工作机制的一个激动人心的变化,这可能会在未来显著提升Python的性能。
具体来说,这为逐步的性能改进打开了大门,这些改进可能会逐渐提升Python的性能,使其与其它编程语言更具有竞争力。尽管如此,由于目前仍处于初期阶段,复制和修补的JIT技术还很新且轻量级,因此在们开始从JIT编译器中获得显著的性能提升之前,还需要进行更多的重大改进。
如何尝试 JIT?
JIT 编译器在 3.13 中是“实验性”的,并且没有提供开箱即用的支持(至少当使用 pyenv 下载 3.13.0rc2 时)。您可以通过执行以下操作来启用实验性 JIT 支持:
$ PYTHON_CONFIGURE_OPTS="--enable-experimental-jit" pyenv install 3.13-dev
python-build: use openssl@3 from homebrew
python-build: use readline from homebrew
Cloning https://github.com/python/cpython...
Installing Python-3.13-dev...
python-build: use tcl-tk from homebrew
python-build: use readline from homebrew
python-build: use ncurses from homebrew
python-build: use zlib from xcode sdk
Installed Python-3.13-dev to /Users/drew.silcock/.pyenv/versions/3.13-dev
$ python -c 'import sysconfig;print("JIT enabled 🚀" if "-D_Py_JIT" in sysconfig.get_config_var("PY_CORE_CFLAGS") else "JIT disabled 😒")'
JIT enabled 🚀
你可以在PEP 744的讨论页面上找到一些额外的配置选项(比如,要启用JIT编译器,需要在程序运行时加上 -X jit=1
参数等)。
这个测试只能确认JIT是否在编译时被启用,而不能判断它当前是否正在工作状态(例如,是否在运行时被关闭)。虽然可以在程序运行时检查JIT是否启用,但这稍微复杂一些。这里有一个脚本可以帮助你弄清楚这一点(来源于PEP 744的讨论页面):
import _opcode
import types
def is_jitted(f: types.FunctionType) -> bool:
for i in range(0, len(f.__code__.co_code), 2):
try:
_opcode.get_executor(f.__code__, i)
except RuntimeError:
# This isn't a JIT build:
return False
except ValueError:
# No executor found:
continue
return True
return False
def fibonacci(n):
a, b = 0, 1
for _ in range(n):
a, b = b, a + b
return a
def main():
fibonacci(100)
if is_jitted(fibonacci):
print("JIT enabled 🚀")
else:
print("Doesn't look like the JIT is enabled 🥱")
if __name__ == "__main__":
main()
在PEP 744的讨论中,有人提到了PYTHON_JIT=0/1和-X jit=0/1这两种设置方式——测试后发现-X命令行选项似乎并没有什么效果,但是设置环境变量似乎能够达到预期的效果。
$ python is-jit.py
JIT enabled 🚀
$ PYTHON_JIT=0 python is-jit.py
Doesn't look like the JIT is enabled 🥱
总结
Python 3.13 版本带来了一些激动人心的全新概念和功能,这对 Python 的运行时环境来说是一个巨大的进步。虽然这些更新可能不会立即改变你编写和执行 Python 代码的方式,但可以预见,随着自由线程和即时编译器(JIT)技术的不断成熟和普及,它们将在未来几个月甚至几年内,逐渐提升 Python 代码的性能,尤其是对那些需要大量 CPU 计算的任务。