python中的GIL详解

GIL 是什么

首先需要明确的一点是 GIL 并不是 Python 的特性,它是在实现 Python 解析器 (CPython) 时所引入的一个概念。就好比 C++ 是一套语言(语法)标准,但是可以用不同的编译器来编译成可执行代码。有名的编译器例如 GCC,INTEL C++,Visual C++ 等。Python 也一样,同样一段代码可以通过 CPython,PyPy,Psyco 等不同的 Python 执行环境来执行。像其中的 JPython 就没有 GIL。然而因为 CPython 是大部分环境下默认的 Python 执行环境。所以在很多人的概念里 CPython 就是 Python,也就想当然的把 GIL 归结为 Python 语言的缺陷。所以这里要先明确一点:GIL 并不是 Python 的特性,Python 完全可以不依赖于 GIL。

那么 CPython 实现中的 GIL 又是什么呢?GIL 全称 Global Interpreter Lock 为了避免误导,我们还是来看一下官方给出的解释:

In CPython, the global interpreter lock, or GIL, is a mutex that prevents multiple native 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.)

好吧,是不是看上去很糟糕?一个防止多线程并发执行机器码的一个 Mutex,乍一看就是个 BUG 般存在的全局锁嘛!别急,我们下面慢慢的分析。

 

为什么会有 GIL

由于物理上得限制,各 CPU 厂商在核心频率上的比赛已经被多核所取代。为了更有效的利用多核处理器的性能,就出现了多线程的编程方式,而随之带来的就是线程间数据一致性和状态同步的困难。即使在 CPU 内部的 Cache 也不例外,为了有效解决多份缓存之间的数据同步时各厂商花费了不少心思,也不可避免的带来了一定的性能损失。

Python 当然也逃不开,为了利用多核,Python 开始支持多线程。而解决多线程之间数据完整性和状态同步的最简单方法自然就是加锁。 于是有了 GIL 这把超级大锁,而当越来越多的代码库开发者接受了这种设定后,他们开始大量依赖这种特性(即默认 python 内部对象是 thread-safe 的,无需在实现时考虑额外的内存锁和同步操作)。

慢慢的这种实现方式被发现是蛋疼且低效的。但当大家试图去拆分和去除 GIL 的时候,发现大量库代码开发者已经重度依赖 GIL 而非常难以去除了。有多难?做个类比,像 MySQL 这样的“小项目”为了把 Buffer Pool Mutex 这把大锁拆分成各个小锁也花了从 5.5 到 5.6 再到 5.7 多个大版为期近 5 年的时间,本且仍在继续。MySQL 这个背后有公司支持且有固定开发团队的产品走的如此艰难,那又更何况 Python 这样核心开发和代码贡献者高度社区化的团队呢?

所以简单的说 GIL 的存在更多的是历史原因。如果推到重来,多线程的问题依然还是要面对,但是至少会比目前 GIL 这种方式会更优雅。

 

GIL 的影响

从上文的介绍和官方的定义来看,GIL 无疑就是一把全局排他锁。毫无疑问全局锁的存在会对多线程的效率有不小影响。甚至就几乎等于 Python 是个单线程的程序。
那么读者就会说了,全局锁只要释放的勤快效率也不会差啊。只要在进行耗时的 IO 操作的时候,能释放 GIL,这样也还是可以提升运行效率的嘛。或者说再差也不会比单线程的效率差吧。理论上是这样,而实际上呢?Python 比你想的更糟。

下面我们就对比下 Python 在多线程和单线程下得效率对比。测试方法很简单,一个循环 1 亿次的计数器函数。一个通过单线程执行两次,一个多线程执行。最后比较执行总时间。测试环境为双核的 Mac pro。注:为了减少线程库本身性能损耗对测试结果带来的影响,这里单线程的代码同样使用了线程。只是顺序的执行两次,模拟单线程。

顺序执行的单线程 (single_thread.py)

 

同时执行的两个并发线程 (multi_thread.py)

 

下图就是测试结果

可以看到 python 在多线程的情况下居然比单线程整整慢了 45%。按照之前的分析,即使是有 GIL 全局锁的存在,串行化的多线程也应该和单线程有一样的效率才对。那么怎么会有这么糟糕的结果呢?

让我们通过 GIL 的实现原理来分析这其中的原因。

 

当前 GIL 设计的缺陷

基于 pcode 数量的调度方式

按照 Python 社区的想法,操作系统本身的线程调度已经非常成熟稳定了,没有必要自己搞一套。所以 Python 的线程就是 C 语言的一个 pthread,并通过操作系统调度算法进行调度(例如 linux 是 CFS)。为了让各个线程能够平均利用 CPU 时间,python 会计算当前已执行的微代码数量,达到一定阈值后就强制释放 GIL。而这时也会触发一次操作系统的线程调度(当然是否真正进行上下文切换由操作系统自主决定)。

伪代码

这种模式在只有一个 CPU 核心的情况下毫无问题。任何一个线程被唤起时都能成功获得到 GIL(因为只有释放了 GIL 才会引发线程调度)。但当 CPU 有多个核心的时候,问题就来了。从伪代码可以看到,从 release GIL 到 acquire GIL 之间几乎是没有间隙的。所以当其他在其他核心上的线程被唤醒时,大部分情况下主线程已经又再一次获取到 GIL 了。这个时候被唤醒执行的线程只能白白的浪费 CPU 时间,看着另一个线程拿着 GIL 欢快的执行着。然后达到切换时间后进入待调度状态,再被唤醒,再等待,以此往复恶性循环。

PS:当然这种实现方式是原始而丑陋的,Python 的每个版本中也在逐渐改进 GIL 和线程调度之间的互动关系。例如先尝试持有 GIL 在做线程上下文切换,在 IO 等待时释放 GIL 等尝试。但是无法改变的是 GIL 的存在使得操作系统线程调度的这个本来就昂贵的操作变得更奢侈了。
关于 GIL 影响的扩展阅读

为了直观的理解 GIL 对于多线程带来的性能影响,这里直接借用的一张测试结果图(见下图)。图中表示的是两个线程在双核 CPU 上得执行情况。两个线程均为 CPU 密集型运算线程。绿色部分表示该线程在运行,且在执行有用的计算,红色部分为线程被调度唤醒,但是无法获取 GIL 导致无法进行有效运算等待的时间。

由图可见,GIL 的存在导致多线程无法很好的立即多核 CPU 的并发处理能力。

那么 Python 的 IO 密集型线程能否从多线程中受益呢?我们来看下面这张测试结果。颜色代表的含义和上图一致。白色部分表示 IO 线程处于等待。可见,当 IO 线程收到数据包引起终端切换后,仍然由于一个 CPU 密集型线程的存在,导致无法获取 GIL 锁,从而进行无尽的循环等待。

简单的总结下就是:Python 的多线程在多核 CPU 上,只对于 IO 密集型计算产生正面效果;而当有至少有一个 CPU 密集型线程存在,那么多线程效率会由于 GIL 而大幅下降。

 

如何避免受到 GIL 的影响

说了那么多,如果不说解决方案就仅仅是个科普帖,然并卵。GIL 这么烂,有没有办法绕过呢?我们来看看有哪些现成的方案。

用 multiprocess 替代 Thread

multiprocess 库的出现很大程度上是为了弥补 thread 库因为 GIL 而低效的缺陷。它完整的复制了一套 thread 所提供的接口方便迁移。唯一的不同就是它使用了多进程而不是多线程。每个进程有自己的独立的 GIL,因此也不会出现进程之间的 GIL 争抢。

当然 multiprocess 也不是万能良药。它的引入会增加程序实现时线程间数据通讯和同步的困难。就拿计数器来举例子,如果我们要多个线程累加同一个变量,对于 thread 来说,申明一个 global 变量,用 thread.Lock 的 context 包裹住三行就搞定了。而 multiprocess 由于进程之间无法看到对方的数据,只能通过在主线程申明一个 Queue,put 再 get 或者用 share memory 的方法。这个额外的实现成本使得本来就非常痛苦的多线程程序编码,变得更加痛苦了。具体难点在哪有兴趣的读者可以扩展阅读这篇文章

用其他解析器

之前也提到了既然 GIL 只是 CPython 的产物,那么其他解析器是不是更好呢?没错,像 JPython 和 IronPython 这样的解析器由于实现语言的特性,他们不需要 GIL 的帮助。然而由于用了 Java/C# 用于解析器实现,他们也失去了利用社区众多 C 语言模块有用特性的机会。所以这些解析器也因此一直都比较小众。毕竟功能和性能大家在初期都会选择前者,Done is better than perfect。

所以没救了么?

当然 Python 社区也在非常努力的不断改进 GIL,甚至是尝试去除 GIL。并在各个小版本中有了不少的进步。有兴趣的读者可以扩展阅读这个 Slide

另一个改进 Reworking the GIL
– 将切换颗粒度从基于 opcode 计数改成基于时间片计数
– 避免最近一次释放 GIL 锁的线程再次被立即调度
– 新增线程优先级功能(高优先级线程可以迫使其他线程释放所持有的 GIL 锁)

 

总结

Python GIL 其实是功能和性能之间权衡后的产物,它尤其存在的合理性,也有较难改变的客观因素。从本分的分析中,我们可以做以下一些简单的总结:

    • 因为 GIL 的存在,只有 IO Bound 场景下得多线程会得到较好的性能
    • 如果对并行计算性能较高的程序可以考虑把核心部分也成 C 模块,或者索性用其他语言实现
    • GIL 在较长一段时间内将会继续存在,但是会不断对其进行改进