全局解释性锁,简称 GIL (Global Interpreter Lock),它是什么,官方有如下解释:

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 在执行 Python 字节码时保护访问 Python 对象而阻止多个线程执行的互斥锁,主要因为 CPython 的解释器非线程安全。
  • GIL 非 Python 语言特性,而是依赖于解释器的实现,CPython 实现了 GIL 机制
  • GIL 保证 Python 解释器运行时,同一时刻只有一个线程运行,保证内存管理安全
  • 目前已经有许多功能依赖 GIL

常见的 Python 解释器有如下几种,以及这些解释器是否存在 GIL

  • CPythonC 语言开发的解释器,默认官方版本,使用最为广泛,有 GIL
  • IPython:基于 CPython 开发的交互式解释器,只是增强了交互功能,执行功能与 CPython 完全一样
  • PyPy:目标是加快执行速度,采用 JIT 技术,对 Python 代码进行动态编译(不是解释),可显著提高执行速度,但执行结果可能与 CPython 不同。有 GIL,但其开发者宣布发布去掉 GIL 的版本
  • Jython:运行在 Java 平台上的 Python 解释器,可以把 Python 代码编译成 Java 字节码,依赖 Java 平台,没有 GIL
  • IronPython:和 Jython 类似,执行在微软 .Net 平台的 Python 解释器,可以把 Python 代码编译成 .Net 字节码依赖 .Net 平台,没有 GIL

GIL Problem:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import threading

def loop():
count = 0
while count <= 1000000000:
count += 1

# 2 个线程执行 loop 方法
t1 = threading.Thread (target=loop)
t2 = threading.Thread (target=loop)

t1.start ()
t2.start ()
t1.join ()
t2.join ()

上面这段代码,虽然开了 2 个线程执行,但我们观察 CPU 使用情况,发现其只能跑满一个核心。

由于 GIL 的存在,当线程被操作系统唤醒后,必须拿到 GIL 锁后才能执行代码,也就是说同一时刻永远只有一个线程在执行,这就导致如果我们的程序是 CPU 密集运算型的任务,那么使用 Python 多线程是不能提高效率的。

但即使有 GIL 的存在,理论来上来说,只要 GIL 释放的够勤快,多线程执行怎么也要比单线程效率高吧?

现实结果是:效率比我们想象的更糟糕!

  • 串行执行 2 次 CPU 密集型任务:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import time
import threading

def loop ():
count = 0
while count <= 5000000000:
count += 1


def main ():
# 串行执行 2 次 CPU 密集型任务
start = time.time ()
loop ()
loop ()
print time.time () - start

if __name__ == '__main__':
main ()

# 540.302778006
  • 2 个线程同时执行 CPU 密集型任务:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
import time
import threading

def loop ():
count = 0
while count <= 5000000000:
count += 1


def main ():
# 2 个线程同时执行 CPU 密集型任务
start = time.time ()

t1 = threading.Thread (target=loop)
t2 = threading.Thread (target=loop)
t1.start ()
t2.start ()
t1.join ()
t2.join ()

print time.time () - start

if __name__ == '__main__':
main ()

# 573.972337961

上面的代码分别模拟了一个 CPU 密集型任务在串行执行 2 次和 2 个线程同时执行的场景,执行结果发现,多线程的效率还不如串行效率高!

为什么会导致这种情况?我们来分析其背后的工作原理。

How GIL?

由于 Python 的线程就是 C 语言的 pthread,它是通过操作系统调度算法调度执行。而 Python 的执行是基于 opcode 数量的调度方式,简单来说就是每执行一定数量的字节码,或遇到系统 IO 时,会强制释放 GIL,然后触发一次操作系统的线程调度。

单核 CPU 下的多线程

如果是单核 CPU 情况下,在多线程执行时,每次线程 A 释放 GIL 后,被唤醒的线程 B 能够立即拿到 GIL,能够无缝执行,执行流程如下图:

1524717396

多核 CPU 下的多线程

但在多核 CPU 情况下多线程执行时,一个线程在 CPU0 执行完之后释放 GIL,其他 CPU 上的线程都会进行竞争,但 CPU0 可能又马上获取到了 GIL,这就导致其他 CPU 上被唤醒的线程只能眼巴巴地看着 CPU0 上的线程欢快地执行着,而自己只能等待,直到又被切换到待调度的状态,这就会产生多核 CPU 频繁进行线程切换,消耗着资源,但只有一个线程能够拿到 GIL 真正执行 Python 代码,这就导致多线程在多核 CPU 情况下,效率还不如单线程执行效率高。执行流程如下图:

1524709489

绿色部分是线程获得了 GIL 并进行有效的 CPU 运算,红色部分是被唤醒的线程由于没有争夺到 GIL,只能无效地等待,无法充分利用 CPU 的并行运算能力。这就是多线程在多核 CPU 下,执行效率还不如单线程或单核 CPU 效率高的原因。

多线程 IO 密集型任务

我们再进一步试想,如果多线程执行 IO 密集型任务,效率如何?

答案是比单线程效率要高。

这是由于 IO 密集型的任务,大部分时间都在等待 IO 上,很少消耗 CPU 的资源,所以在 IO 密集型任务的场景下,使用多线程是可以提升效率的。

Why GIL?

既然 GIL 的影响这么大,那为什么 Python 的解释器 CPython 在设计时要采用这种方式呢?

这就要追溯历史原因,2000 年以前,各个 CPU 厂商都在努力提升核心频率从而提高计算机的性能,但到 2000 年以后逐渐遇到天花板,之后提升方向改为多核心方向。

为了更有效的利用多核心 CPU,就出现了多线程的编程方式,而随之带来的就是线程间数据一致性和状态同步的困难。

Python 设计者在设计解释器时,可能没有想到 CPU 的性能提升会这么快转为多核心方向发展,所以在当时的场景下,设计一个全局锁是那个时代保护多线程资源一致性的最简单经济的设计方案。

而随着多核心时代来临,当大家试图去拆分和去除 GIL 的时候,发现大量库的代码开发者已经重度依赖 GIL(默认 Pythonn 内部对象是线程安全的,无需在开发时额外加锁),所以这个去除 GIL 的任务变得复杂且难以实现。

所以简单来说 GIL 的存在更多的是历史原因,如果推倒重来重新设计,面对多线程问题可能设计得会更为优雅。

How to solve?

既然 GIL 存在会导致这么多问题,那我们有什么方式可以绕开这些问题,提高程序性能?总结如下:

  • IO 密集型任务场景,多线程可以提高运行效率(推荐)
  • 使用没有 GIL 的 Python 解释器(不推荐)
  • CPU 密集型任务场景,可改为多进程执行(推荐)
  • 编写 Python 的 C 扩展模块,把 CPU 密集型任务交给 C 模块处理(编码复杂,不推荐)
  • 更换其他语言实现 CPU 密集型任务