In CPython, the global interpreter lock, or GIL, is a mutex that protects access to Python objects, preventing multiple threads from executing Python bytecodes at once. This lock is necessary mainly because CPython's memory management is not thread-safe. (However, since the GIL exists, other features have grown to depend on the guarantees that it enforces.)
我们从这个定义中,可以看到几个重点:
- GIL 是存在于 CPython 解释器中的,属于解释器层级,而并非属于 Python 的语言特性。也就是说,如果你自己有能力实现一个 Python 解释器,完全可以不使用 GIL
- GIL 是为了让解释器在执行 Python 代码时,同一时刻只有一个线程在运行,以此保证内存管理是安全的
- 历史原因,现在很多 Python 项目已经习惯依赖 GIL(开发者认为 Python 就是线程安全的,写代码时对共享资源的访问不会加锁)
在这里我想强调的是,因为 Python 默认的解释器是 CPython,GIL 是存在于 CPython 解释器中的,我们平时说到 GIL 就认为它是 Python 语言的问题,其实这个表述是不准确的。
其实除了 CPython 解释器,常见的 Python 解释器还有如下几种:
- CPython:C 语言开发的解释器,官方默认使用,目前使用也最为广泛,存在 GIL
- IPython:基于 CPython 开发的交互式解释器,只是增强了交互功能,执行过程与 CPython 完全一样
- PyPy:目标是加快执行速度,采用 JIT 技术,对 Python 代码进行动态编译(不是解释),可以显著提高代码的执行速度,但执行结果可能与 CPython 不同,存在 GIL
- Jython:运行在 Java 平台的 Python 解释器,可以把 Python 代码编译成 Java 字节码,依赖 Java 平台,不存在 GIL
- IronPython:和 Jython 类似,运行在微软的 .Net 平台下的 Python 解释器,可以把 Python 代码编译成 .Net 字节码,不存在 GIL
虽然有这么多 Python 解释器,但使用最广泛的依旧是官方提供的 CPython,它默认是有 GIL 的。
其实,由于 Python 的线程就是 C 语言的 pthread,它是通过操作系统调度算法调度执行的。
Python 2.x 的代码执行是基于 opcode 数量的调度方式,简单来说就是每执行一定数量的字节码,或遇到系统 IO 时,会强制释放 GIL,然后触发一次操作系统的线程调度。
虽然在 Python 3.x 进行了优化,基于固定时间的调度方式,就是每执行固定时间的字节码,或遇到系统 IO 时,强制释放 GIL,触发系统的线程调度。
但这种线程的调度方式,都会导致同一时刻只有一个线程在运行。
而线程在调度时,又依赖系统的 CPU 环境,也就是在单核 CPU 或多核 CPU 下,多线程在调度切换时的成本是不同的。
如果是在单核 CPU 环境下,多线程在执行时,线程 A 释放了 GIL 锁,那么被唤醒的线程 B 能够立即拿到 GIL 锁,线程 B 可以无缝接力继续执行,执行流程如下图:
而如果在在多核 CPU 环境下,当多线程执行时,线程 A 在 CPU0 执行完之后释放 GIL 锁,其他 CPU 上的线程都会进行竞争。
但 CPU0 上的线程 B 可能又马上获取到了 GIL,这就导致其他 CPU 上被唤醒的线程,只能眼巴巴地看着 CPU0 上的线程愉快地执行着,而自己只能等待,直到又被切换到待调度的状态,这就会产生多核 CPU 频繁进行线程切换,消耗资源,这种情况也被叫做「CPU颠簸」。整个执行流程如下图:
图中绿色部分是线程获得了 GIL 并进行有效的 CPU 运算,红色部分是被唤醒的线程由于没有争夺到 GIL,只能无效等待,无法充分利用 CPU 的并行运算能力。
这就是多线程在多核 CPU 下,执行效率还不如单线程或单核 CPU 效率高的原因。
到此,我们可以得出一个结论:如果使用多线程运行一个 CPU 密集型任务,那么 Python 多线程是无法提高运行效率的。
别急,你以为事情就这样结束了吗?
我们还需要考虑另一种场景:如果多线程运行的不是一个 CPU 密集型任务,而是一个 IO 密集型的任务,结果又会如何呢?
答案是,多线程可以显著提高运行效率!
其实原因也很简单,因为 IO 密集型的任务,大部分时间都花在等待 IO 上,并没有一直占用 CPU 的资源,所以并不会像上面的程序那样,进行无效的线程切换。
例如,如果我们想要下载 2 个网页的数据,也就是发起 2 个网络请求,如果使用单线程的方式运行,只能是依次串行执行,其中等待的总耗时是 2 个网络请求的时间之和。
而如果采用 2 个线程的方式同时处理,这 2 个网络请求会同时发送,然后同时等待数据返回(IO等待),最终等待的时间取决于耗时最久的线程时间,这会比串行执行效率要高得多。
所以,如果需要运行 IO 密集型任务,Python 多线程是可以提高运行效率的。
要搞清楚GIL对多线程程序的影响就要了解GIL的运行基本原理。
- 单核CPU情况
CPython的Pthread是通过操作系统调度算法调度执行。
Python解释器每执行一定数量的字节码,或遇到系统IO时,会强制释放GIL,然后触发一次操作系统的线程调度,实现单核CPU的充分利用,并且在单核上释放和重新执行的时间间隔非常短。
- 多核CPU情况
多核情况下多线程执行时,一个线程在CPU-A执行完之后释放GIL,其他CPU上的线程都会进行竞争,但CPU-A可能又马上获取到了GIL。
这就导致其他CPU上被唤醒的线程只能眼巴巴地看着CPU-A上的线程再次执行,而自己只能等待,直到又被切换到待调度的状态。
这就会产生多核CPU频繁进行线程切换,消耗着资源,但只有一个线程能够拿到GIL真正执行Python代码,这就导致多线程在多核CPU情况下,效率还不如单线程执行效率高。
这种情况非常类似于网络编程中的多个线程监听同一端口造成的惊群现象,只不过是CPU级别的,造成的浪费更加奢侈。
GIL的实际影响
- I/O密集型
在单核CPU上执行多线程时由解释器实现了有效的切换,这一点是很有益处的。
在I/O密集型的诸如网络爬虫等类型的程序即使使用GIL控制下的多线程程序性能也不会像你想象中那么糟糕。
- CPU密集型
对于CPU密集型的计算类程序GIL就有比较大的问题,因为CPU密集型的程序本身没有太多等待,不需要解释器介入并且所有任务只能等待1个核心,其他核心空闲也无法使用,这么看对多核的使用确实很糟糕。
7.抛弃和优化GIL
GIL一直备受争议,为此PEP也多次尝试删除或者优化GIL,但是解释器本身的复杂性和众多GIL下的类库都让GIL移除成为遥不可及的想法。
- 移除GIL
在1999年针对Python 1.5,一个free threading补丁已经尝试实现了这个想法,该补丁来自Greg Stein。
在这个补丁中,GIL被完全的移除,且用细粒度的锁来代替。然而,GIL的移除给单线程程序的执行速度带来了一定的代价。
当用单线程执行时,速度大约降低了40%。使用两个线程展示出了在速度上的提高,但除了这个提高,这个收益并没有随着核数的增加而线性增长。由于执行速度的降低,这一补丁被拒绝了,并且几乎被人遗忘。
1999年多核还是个幻想,但是在现今移除GIL也异常困难,真的移除效果如何也是未知的,只能说回头太难。
- 优化GIL
2009年Antoine Pitrou 在Python 3.2中实现了一个新的GIL,并且带着一些积极的结果。
这是GIL的一次最主要改变,旧的GIL通过对Python指令进行计数来确定何时放弃GIL。
单条Python指令将会包含大量的工作,在新的GIL实现中,用一个固定的超时时间来指示当前的线程以放弃这个锁,使得线程间的切换更加可预测。
8.GIL缺陷的解决方案
python作为生命力极强的热门语言,绝对不会在多核时代坐以待毙。即便有GIL的限制,仍然有许多方法让程序拥抱多核。
- 多进程
Python2.6引入了MultiProcess库来弥补Threading库中GIL带来的缺陷,基于此开发多进程程序,每个进程有单独的GIL,避免多进程之间对GIL的竞争,从而实现多核的利用,但是也带来一些同步和通信问题,这也是必然会出现的。
- Ctypes
CPython的优势就是与C模块的结合,因此可以借助Ctypes调用C的动态库来实现将计算转移,C动态库没有GIL可以实现对多核的利用。
- 协程
协程也是一个很好的手段,在Python3.4之前没有对协程的支持,存在一些三方库的实现,比如gevent和Tornado。
Python3.4之后就内置了asyncio标准库真正实现了协程这一特性。
9.小结
GIL仍然是Python语言里最困难的技术挑战,GIL问题的并不是编程语言的本身问题,换做其他语言只是将问题转移到了用户层面,相反Python的作者尝试将这种问题转移到解释器给使用者呈现一个优雅的语言。
虽然多核时代的到来暴露了GIL的缺陷,但是Python决策者和社区开发者已经做出了许多其他措施来拥抱多核,无知地诟病GIL是不明智的做法。
如同生产关系要适应生产力的发展一样,抛开历史背景谈机制的优劣,都是有失偏颇的,所以对待GIL要辩证看待。