CPython 有 GIL 是因为当年设计 CPython 的人偷懒吗? ① —— 简单的答案是:不仅没有偷懒,相反 GIL 是一个杰出的设计。
一、Greg Stein 的尝试
Guido van Rossum 提到 ② ,在 1999 年,Greg Stein(及 Mark Hammond ?)曾尝试开发过一个无 GIL 的 Python(据信是 1.5 版)分支,该分支对“所有变量”施以细粒度线程锁。这让 Python 的单线程性能下降了两倍,这足以抵消多线程所能为 Python 带来的性能提升。
这次尝试让 Python 在取消 GIL 这件事上变得非常谨慎,GIL 得以保留至今。但是反过来说,我们发现 Guido 在最初所选择的 GIL —— 其实已经是最优方案了。
二、GIL 为什么快
拿数据库来进行类比,使用粗粒度的 GIL 就好象在操作数据时,直接锁定整个数据库。SQLite(非 WAL 模式)就是这样一种直接锁库的实现,同时 SQLite 也是在单线程下最快的主流 SQL 数据库。
使用细粒度锁来代替 GIL,类似于锁定到每个字段。由于过于影响性能,数据库锁定粒度一般不会做到这么细的程度。而对于 Python 解释器来说,由于很难从用户代码中抽离出类似于数据库“行、表”级别的代码段,来进行中等粒度的锁定,所以也就无法做到在不大量损失性能的情况下,来去除 GIL 了。
我们在实际开发中,经常会遇到类似的粒度选择问题。有经验的开发者习惯上会采用一些“简单粗暴”的方法来解决问题,虽然看上去这很初级,但是这样做却是最安全、稳定,和高效的。相反许多初学者反而会采用很多复杂、“高端”的方法来解决问题,这些代码往往都不够安全、稳定,同时又很低效。
事实往往和表面上看到的不太一样。对于有经验的开发者来说,简单粗暴的大规模数据锁、GIL 等方案,其实是经常会被用到的。这往往是一种下意识的选择,背后是有丰富的开发经验来支撑的,并不是偷懒的结果。
三、PyPy 的尝试
PyPy 使用 STM(Software Transactional Memory)来去除 GIL,这有点像 Python 的 ZODB 数据库基于 Conflict 的并发机制那样,是一种无锁实现。但是,STM 目前不适用于两种场合 ③ :
大量的 I/O 阻塞
大量的运算在 C Extensions 中进行
相反 GIL 对这两种场合非常在行。事实上并行 I/O 是多线程主要解决的问题,并行计算在 Python 中又常常是交给 C Extensions 处理的,所以 CPython GIL 在实际使用时,在并发性能上,并不会输于无 GIL 的 PyPy STM。这再次证明了 GIL 是一个相当杰出的设计。
四、我的方案
顺便说一下,如果让我来设计 Python,要支持多线程的话,我一定会采用 GIL。但是多线程就一定是必须的吗?事实上在我看来,多线程并不是一个很好的并发模型。在需要多核进行并行计算的时候,我们可以采用更安全的多进程模式,在需要高并发 I/O 的时候,我们则可以选择更加安全、高效的“异步”和“协程”模式 —— 如果不使用多线程,我们自然就可以去除 GIL 了。
所以,我启动了一个叫做“C10K ④ ”的项目,通过 DLL Injection 在不修改用户程序的情况下,将线程自动替换成协程,这能把任意语言编写的程序都变成异步程序,并且顺便把 Python 的 GIL 给去掉了 —— 这也是我目前认为,最漂亮的去除 GIL 的方法了。我在视频「标准线程的协程替换 ⑤ 」中做了详细讲解。
五、GIL 出现的历史原因
Python 开始于 1989年 12月,为 Amoeba 操作系统(一种分布式 POSIX 系统)设计,当时是小型机时代,而 PC 机则处于 386/486 时代。虽然在 90 年代我在国内的科研单位已经看到有堆叠了大量 CPU 的工程机(今天叫做众核机),但是“真正意义上的”多核(对称双 CPU 方案,SMP)实际商用的产品 ⑥ 出现于 1999 年。而最早的单一多核 CPU ⑦ 出现于 2000~2001 年。也就是说,在 Python 出现的那个时代,多核尚未实质性出现。
既然没有多核,那么也就不存在“基于多核多线”的“并行计算”了。由于当时 Unix 程序员习惯上是使用多进程来处理多任务的(进程在 Unix 中很轻,尤其是内存在进程间是通过“写复制”来共享的),所以多线程在单核时代的“唯一用途”是配合多进程,在进程中并行化处理阻塞式 I/O 用的。
由于多线程会对 CPU 产生竞争,在单核时代,线程开得越多,系统性能就会越低。所以在 Python 出现的那个年代,如果要提升程序性能,正确的做法并不是去增加活跃的线程数量,相反应该把活跃线程数减少,以降低 CPU 竞争。既然在“单核时代”线程并没有“多核并行计算”的用途,仅仅是处理阻塞式 I/O 用的,那么提升性能最好的方法就是:把整个程序锁住,让程序中只能有一个活跃线程 —— 这就是 GIL。
最后我用一个极端假设来说明这个问题:“假设可以在没有任何性能损耗的情况下去掉 Python 的 GIL”—— 那么在单核时代,GIL CPython 还是会比 GIL-free CPython 性能更高 —— 所以站在当时的角度来看,只能说 GIL 干得漂亮。
六、GIL 的真正问题
GIL 并不影响 I/O 并行,在需要多核并行计算的时候,CPython 会通过 C Extensions 和多进程来解决问题,因此 GIL 对并行计算的影响其实经常不大。多进程的 IPC 损耗也没有那么泛化,主要是集中在与共享内存相关的 ⑴ 高频内存共享 ⑵ 大内存共享 —— 这两个场景中。这时经过权衡,GIL 对性能的影响“也许”会超过多进程操作临界资源所带来的安全性好处,这最终会影响到程序开发的自由度。GIL 阻碍了程序的“按需并行 ⑧ ” —— 这才是更本质的问题。