极客时间已完结课程限时免费阅读

23 | 你真的懂Python GIL(全局解释器锁)吗?

23 | 你真的懂Python GIL(全局解释器锁)吗?-极客时间

23 | 你真的懂Python GIL(全局解释器锁)吗?

讲述:冯永吉

时长10:11大小8.17M

你好,我是景霄。
前面几节课,我们学习了 Python 的并发编程特性,也了解了多线程编程。事实上,Python 多线程另一个很重要的话题——GIL(Global Interpreter Lock,即全局解释器锁)却鲜有人知,甚至连很多 Python“老司机”都觉得 GIL 就是一个谜。今天我就来为你解谜,带你一起来看 GIL。

一个不解之谜

耳听为虚,眼见为实。我们不妨先来看一个例子,让你感受下 GIL 为什么会让人不明所以。
比如下面这段很简单的 cpu-bound 代码:
def CountDown(n):
while n > 0:
n -= 1
现在,假设一个很大的数字 n = 100000000,我们先来试试单线程的情况下执行 CountDown(n)。在我手上这台号称 8 核的 MacBook 上执行后,我发现它的耗时为 5.4s。
这时,我们想要用多线程来加速,比如下面这几行操作:
from threading import Thread
n = 100000000
t1 = Thread(target=CountDown, args=[n // 2])
t2 = Thread(target=CountDown, args=[n // 2])
t1.start()
t2.start()
t1.join()
t2.join()
我又在同一台机器上跑了一下,结果发现,这不仅没有得到速度的提升,反而让运行变慢,总共花了 9.6s。
我还是不死心,决定使用四个线程再试一次,结果发现运行时间还是 9.8s,和 2 个线程的结果几乎一样。
这是怎么回事呢?难道是我买了假的 MacBook 吗?你可以先自己思考一下这个问题,也可以在自己电脑上测试一下。我当然也要自我反思一下,并且提出了下面两个猜想。
第一个怀疑:我的机器出问题了吗?
这不得不说也是一个合理的猜想。因此我又找了一个单核 CPU 的台式机,跑了一下上面的实验。这次我发现,在单核 CPU 电脑上,单线程运行需要 11s 时间,2 个线程运行也是 11s 时间。虽然不像第一台机器那样,多线程反而比单线程更慢,但是这两次整体效果几乎一样呀!
看起来,这不像是电脑的问题,而是 Python 的线程失效了,没有起到并行计算的作用。
顺理成章,我又有了第二个怀疑:Python 的线程是不是假的线程?
Python 的线程,的的确确封装了底层的操作系统线程,在 Linux 系统里是 Pthread(全称为 POSIX Thread),而在 Windows 系统里是 Windows Thread。另外,Python 的线程,也完全受操作系统管理,比如协调何时执行、管理内存资源、管理中断等等。
所以,虽然 Python 的线程和 C++ 的线程本质上是不同的抽象,但它们的底层并没有什么不同。

为什么有 GIL?

看来我的两个猜想,都不能解释开头的这个未解之谜。那究竟谁才是“罪魁祸首”呢?事实上,正是我们今天的主角,也就是 GIL,导致了 Python 线程的性能并不像我们期望的那样。
GIL,是最流行的 Python 解释器 CPython 中的一个技术术语。它的意思是全局解释器锁,本质上是类似操作系统的 Mutex。每一个 Python 线程,在 CPython 解释器中执行时,都会先锁住自己的线程,阻止别的线程执行。
当然,CPython 会做一些小把戏,轮流执行 Python 线程。这样一来,用户看到的就是“伪并行”——Python 线程在交错执行,来模拟真正并行的线程。
那么,为什么 CPython 需要 GIL 呢?这其实和 CPython 的实现有关。下一节我们会讲 Python 的内存管理机制,今天先稍微提一下。
CPython 使用引用计数来管理内存,所有 Python 脚本中创建的实例,都会有一个引用计数,来记录有多少个指针指向它。当引用计数只有 0 时,则会自动释放内存。
什么意思呢?我们来看下面这个例子:
>>> import sys
>>> a = []
>>> b = a
>>> sys.getrefcount(a)
3
这个例子中,a 的引用计数是 3,因为有 a、b 和作为参数传递的 getrefcount 这三个地方,都引用了一个空列表。
这样一来,如果有两个 Python 线程同时引用了 a,就会造成引用计数的 race condition,引用计数可能最终只增加 1,这样就会造成内存被污染。因为第一个线程结束时,会把引用计数减少 1,这时可能达到条件释放内存,当第二个线程再试图访问 a 时,就找不到有效的内存了。
所以说,CPython 引进 GIL 其实主要就是这么两个原因:
一是设计者为了规避类似于内存管理这样的复杂的竞争风险问题(race condition);
二是因为 CPython 大量使用 C 语言库,但大部分 C 语言库都不是原生线程安全的(线程安全会降低性能和增加复杂度)。

GIL 是如何工作的?

下面这张图,就是一个 GIL 在 Python 程序的工作示例。其中,Thread 1、2、3 轮流执行,每一个线程在开始执行时,都会锁住 GIL,以阻止别的线程执行;同样的,每一个线程执行完一段后,会释放 GIL,以允许别的线程开始利用资源。
细心的你可能会发现一个问题:为什么 Python 线程会去主动释放 GIL 呢?毕竟,如果仅仅是要求 Python 线程在开始执行时锁住 GIL,而永远不去释放 GIL,那别的线程就都没有了运行的机会。
没错,CPython 中还有另一个机制,叫做 check_interval,意思是 CPython 解释器会去轮询检查线程 GIL 的锁住情况。每隔一段时间,Python 解释器就会强制当前线程去释放 GIL,这样别的线程才能有执行的机会。
不同版本的 Python 中,check interval 的实现方式并不一样。早期的 Python 是 100 个 ticks,大致对应了 1000 个 bytecodes;而 Python 3 以后,interval 是 15 毫秒。当然,我们不必细究具体多久会强制释放 GIL,这不应该成为我们程序设计的依赖条件,我们只需明白,CPython 解释器会在一个“合理”的时间范围内释放 GIL 就可以了。
整体来说,每一个 Python 线程都是类似这样循环的封装,我们来看下面这段代码:
for (;;) {
if (--ticker < 0) {
ticker = check_interval;
/* Give another thread a chance */
PyThread_release_lock(interpreter_lock);
/* Other threads may run now */
PyThread_acquire_lock(interpreter_lock, 1);
}
bytecode = *next_instr++;
switch (bytecode) {
/* execute the next instruction ... */
}
}
从这段代码中,我们可以看到,每个 Python 线程都会先检查 ticker 计数。只有在 ticker 大于 0 的情况下,线程才会去执行自己的 bytecode。

Python 的线程安全

不过,有了 GIL,并不意味着我们 Python 编程者就不用去考虑线程安全了。即使我们知道,GIL 仅允许一个 Python 线程执行,但前面我也讲到了,Python 还有 check interval 这样的抢占机制。我们来考虑这样一段代码:
import threading
n = 0
def foo():
global n
n += 1
threads = []
for i in range(100):
t = threading.Thread(target=foo)
threads.append(t)
for t in threads:
t.start()
for t in threads:
t.join()
print(n)
如果你执行的话,就会发现,尽管大部分时候它能够打印 100,但有时侯也会打印 99 或者 98。
这其实就是因为,n+=1这一句代码让线程并不安全。如果你去翻译 foo 这个函数的 bytecode,就会发现,它实际上由下面四行 bytecode 组成:
>>> import dis
>>> dis.dis(foo)
LOAD_GLOBAL 0 (n)
LOAD_CONST 1 (1)
INPLACE_ADD
STORE_GLOBAL 0 (n)
而这四行 bytecode 中间都是有可能被打断的!
所以,千万别想着,有了 GIL 你的程序就可以高枕无忧了,我们仍然需要去注意线程安全。正如我开头所说,GIL 的设计,主要是为了方便 CPython 解释器层面的编写者,而不是 Python 应用层面的程序员。作为 Python 的使用者,我们还是需要 lock 等工具,来确保线程安全。比如我下面的这个例子:
n = 0
lock = threading.Lock()
def foo():
global n
with lock:
n += 1

如何绕过 GIL?

学到这里,估计有的 Python 使用者感觉自己像被废了武功一样,觉得降龙十八掌只剩下了一掌。其实大可不必,你并不需要太沮丧。Python 的 GIL,是通过 CPython 的解释器加的限制。如果你的代码并不需要 CPython 解释器来执行,就不再受 GIL 的限制。
事实上,很多高性能应用场景都已经有大量的 C 实现的 Python 库,例如 NumPy 的矩阵运算,就都是通过 C 来实现的,并不受 GIL 影响。
所以,大部分应用情况下,你并不需要过多考虑 GIL。因为如果多线程计算成为性能瓶颈,往往已经有 Python 库来解决这个问题了。
换句话说,如果你的应用真的对性能有超级严格的要求,比如 100us 就对你的应用有很大影响,那我必须要说,Python 可能不是你的最优选择。
当然,可以理解的是,我们难以避免的有时候就是想临时给自己松松绑,摆脱 GIL,比如在深度学习应用里,大部分代码就都是 Python 的。在实际工作中,如果我们想实现一个自定义的微分算子,或者是一个特定硬件的加速器,那我们就不得不把这些关键性能(performance-critical)代码在 C++ 中实现(不再受 GIL 所限),然后再提供 Python 的调用接口。
总的来说,你只需要重点记住,绕过 GIL 的大致思路有这么两种就够了:
绕过 CPython,使用 JPython(Java 实现的 Python 解释器)等别的实现;
把关键性能代码,放到别的语言(一般是 C++)中实现。

总结

今天这节课,我们先通过一个实际的例子,了解了 GIL 对于应用的影响;之后我们适度剖析了 GIL 的实现原理,你不必深究一些原理的细节,明白其主要机制和存在的隐患即可。
自然,我也为你提供了绕过 GIL 的两种思路。不过还是那句话,很多时候,我们并不需要过多纠结 GIL 的影响。

思考题

最后,我给你留下两道思考题。
第一问,在我们处理 cpu-bound 的任务(文中第一个例子)时,为什么有时候使用多线程会比单线程还要慢些?
第二问,你觉得 GIL 是一个好的设计吗?事实上,在 Python 3 之后,确实有很多关于 GIL 改进甚至是取消的讨论,你的看法是什么呢?你在平常工作中有被 GIL 困扰过的场景吗?
欢迎在留言区写下你的想法,也欢迎你把今天的内容分享给你的同事朋友,我们一起交流、一起进步。
分享给需要的人,Ta购买本课程,你将得18
生成海报并分享

赞 27

提建议

上一篇
22 | 并发编程之Asyncio
下一篇
24 | 带你解析 Python 垃圾回收机制
unpreview
 写留言

精选留言(36)

  • 小侠龙旋风
    2019-07-06
    先mark一下学到的知识点: 一、查看引用计数的方法:sys.getrefcount(a) 二、CPython引进GIL的主要原因是: 1. 设计者为了规避类似内存管理这样的复杂竞争风险问题(race condition); 2. CPython大量使用C语言库,但大部分C语言库都不是线程安全的(线程安全会降低性能和增加复杂度)。 三、绕过GIL的两种思路: 1. 绕过CPython,使用JPython等别的实现; 2. 把关键性能代码放到其他语言中实现,比如C++。 问答老师的问题: 1. cpu-bound属于计算密集型程序,用多线程运行时,每个线程在开始执行时都会锁住GIL、执行完会释放GIL,这两个步骤比较费时。相比单线程就没有切换线程的问题,所以更快。 相反,在处理多阻塞高延迟的IO密集型程序时,因为多线程有check interval机制,若遇阻塞,CPython会强制当前线程让出(释放)GIL,给其他线程执行的机会。所以能提高程序的执行效率。 2. 第二个问题摘抄了知乎上的讨论: 在python3中,GIL不使用ticks计数,改为使用计时器(执行时间达到阈值后interval=15毫秒,当前线程释放GIL),这样对CPU密集型程序更加友好,但依然没有解决GIL导致的同一时间只能执行一个线程的问题,所以效率依然不尽如人意。多核多线程比单核多线程更差,原因是单核下多线程,每次释放GIL,唤醒的那个线程都能获取到GIL锁,所以能够无缝执行,但多核下,CPU0释放GIL后,其他CPU上的线程都会进行竞争,但GIL可能会马上又被CPU0拿到,导致其他几个CPU上被唤醒后的线程会醒着等待到切换时间后又进入待调度状态,这样会造成线程颠簸(thrashing),导致效率更低。 经常会听到老手说:“python下想要充分利用多核CPU,就用多进程”,原因是什么呢?原因是:每个进程有各自独立的GIL,互不干扰,这样就可以真正意义上的并行执行,所以在python中,多进程的执行效率优于多线程(仅仅针对多核CPU而言)。所以我们能够得出结论:多核下,想做并行提升效率,比较通用的方法是使用多进程,能够有效提高执行效率。
    展开
    共 2 条评论
    135
  • leixin
    2019-07-01
    有重要的一点没讲,GIL会在遇到io的时候自动释放,给其他线程执行的机会,这样Python多线程在io阻塞的多任务中有效。
    共 3 条评论
    40
  • leixin
    2019-07-01
    老师,我曾经去某大厂面试。人家问了我几个问题,比说说,你知道元类吗?Python是如何解决循环引用的?换句话说,Python的垃圾回收机制是如何?我后来自己找了些资料看了,还是,不是理解的特别明白。老师后面的课程能帮我们讲解下吗?
    共 1 条评论
    34
  • 建强
    2019-10-25
    思考题1: 由于GIL采用轮流运行线程的机制,GIL需要在线程之间不断轮流进行切换,线程如果较多或运行时间较长,切换带来的性能损失可能会超过单线程。 思考题2: 个人觉得GIL仍然是一种好的设计,虽然损失了一些性能,但在保证资源不发生冲突,预防死锁方面还是有一定作用的。 以上是个人的一点肤浅理解,请老师指正。
    展开

    作者回复: 讲的不错

    共 2 条评论
    11
  • helloworld
    2019-07-01
    python的单线程和多线程同时都只能利用一颗cpu核心,对于纯cpu heavy任务场景,不涉及到io耗时环节,cpu都是充分利用的,多线程和单线程相比反倒是多了线程切换的成本,所以性能反而不如单线程。
    10
  • SCAR
    2019-07-01
    1.cpu-bound任务的多线程相比单线程,时间的增加在于锁添加的获取和释放的开销结果。 2.返回到python诞生的年代,GIL相对来说是合理而且有效率的,它易于实现,很容易就添加到python中,而且它为单线程程序提供了性能提升。以至于Guido在“It isn't Easy to Remove the GIL”里面说“ I'd welcome a set of patches into Py3k only if the performance for a single-threaded program (and for a multi-threaded but I/O-bound program) does not decrease”。而到现在为止,任何尝试都没有达到这一条件。
    展开
    共 1 条评论
    8
  • jackstraw
    2020-01-15
    关于绕过GIL的第二个方式:将关键的性能代码放到别的语言中(通常C++)实现;这种解决方式指的是在别的语言中使用多线程的方式处理任务么?就是不用python的多线程,而是在别的语言中使用多线程?

    作者回复: 对

    7
  • farFlight
    2019-07-01
    另外,在测试不加锁的 foo 函数的时候,我这里循环测试10000次也不会见到n!=100的情况,这是为什么呢?
    共 1 条评论
    4
  • Kfreer
    2020-05-26
    1在我们处理 cpu-bound 的任务(文中第一个例子)时,为什么有时候使用多线程会比单线程还要慢些? 答:由于CPython中GIL的存在(运行线程前需要先获取GIL),所以即便是多线程运行,同一时刻也只能有一个线程处于运行状态,且切线程之间切换时还要消耗一部分资源。这就导致cpu密集型任务下多线程反而没有单线程运行的快。 2你觉得 GIL 是一个好的设计吗?事实上,在 Python 3 之后,确实有很多关于 GIL 改进甚至是取消的讨论,你的看法是什么呢?你在平常工作中有被 GIL 困扰过的场景吗? 答:不是一个好的设计,仅仅是简化了解释器的设计,且并没有解决线程安全的问题。 总结:CPU密集型任务用多进程并行处理(CPU需多核),I/O密集型任务用协程,理论上协程比多线程的效率还要高。
    展开
    2
  • 程序员人生
    2019-07-01
    t1 = Thread(target=CountDown, args=[n // 2]) 老师,这段代码里面n//2是什么意思?
    共 8 条评论
    2
  • 张巡
    2020-09-13
    这是mackbook被黑的最惨的一次,哈哈哈
    2
  • 自由民
    2019-10-15
    思考题 1.线程切换有开销成本,另外最主要是由于GIL的存在使python的并行为伪并行。 2.我觉得不是好设计,像戴着镣铐跳舞,好处仅仅是简化了解释器的设计。而且它并不能完全解决线程安全问题。但我平时很少用多线程编程,所以还没有实际体会。而像解释器这样的基础设施应该是把脏活留给自己,尽量减少用户的复杂性。类似还有从python2到python3的大变动。
    展开
    1
  • 程序员人生
    2019-07-01
    通过老师的讲解,我觉得GIL有点像java的Synchronized 监视器锁,同一时刻只有一个线程获得监视器锁。所以线程的频繁切换,会增加CPU开销,导致多线程反而速度变慢。
    1
  • A君
    2022-05-27
    如果是通过Threading创建多线程,多线程跑python API封装的C++程序,这样是受限GIL的。而把创建多线程的工作放到C++程序里,就不被GIL所累了。
  • 2021-09-02
    check_interval有可能导致线程饿死吗
  • sugar
    2021-03-17
    处理计算密集任务,多线程的来回切换消耗资源,所以不及单线程
  • zys
    2021-03-11
    一核心的cpu对应一个完美的python执行. 需要多核心并行,多开,或者调用,其他库来使用就行了,,本质来讲,gil习惯了,也没什么啊
  • 苹果
    2020-02-04
    自己的比喻理解 1,单线程,有语文(1),数学(2),英语(3)作业,按照顺序123写问作业 2,多线程,在Cpython解释器种,有GIL存在,按照check interval 机制,是语文作业写5分钟,换着数学作业写5分钟,英语5分钟,在换着语文写:线程阻塞的情况,理解为数学有道题目很难,(I/O密集)提前不做,释放出来, 3,多进程,找两个人帮忙做,三个人分别做语文,数学,英语,最后完成的算终止时间, 线程安全理解为,做语文作业时,不需要用到数学作业里的资料,工具,
    展开
  • Paul Shan
    2019-11-21
    GIL在Python作为脚本或者客户端程序没问题,作为高性能程序多少有点问题。当引入协程后,并发被很好处理,现在只剩下并行不太理想,用的是多进程模型,没有利用操作系统最小调度单元。
  • Paul Shan
    2019-11-21
    Python 多线程是伪多线程。