下载APP
关闭
讲堂
前端训练营
极客商城
客户端下载
兑换中心
企业版
渠道合作
推荐作者

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

2019-07-01 景霄
Python核心技术与实战
进入课程

讲述:冯永吉

时长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 的大致思路有这么两种就够了:

  1. 绕过 CPython,使用 JPython(Java 实现的 Python 解释器)等别的实现;
  2. 把关键性能代码,放到别的语言(一般是 C++)中实现。

总结

今天这节课,我们先通过一个实际的例子,了解了 GIL 对于应用的影响;之后我们适度剖析了 GIL 的实现原理,你不必深究一些原理的细节,明白其主要机制和存在的隐患即可。

自然,我也为你提供了绕过 GIL 的两种思路。不过还是那句话,很多时候,我们并不需要过多纠结 GIL 的影响。

思考题

最后,我给你留下两道思考题。

第一问,在我们处理 cpu-bound 的任务(文中第一个例子)时,为什么有时候使用多线程会比单线程还要慢些?

第二问,你觉得 GIL 是一个好的设计吗?事实上,在 Python 3 之后,确实有很多关于 GIL 改进甚至是取消的讨论,你的看法是什么呢?你在平常工作中有被 GIL 困扰过的场景吗?

欢迎在留言区写下你的想法,也欢迎你把今天的内容分享给你的同事朋友,我们一起交流、一起进步。

© 版权归极客邦科技所有,未经许可不得传播售卖。 页面已增加防盗追踪,如有侵权极客邦将依法追究其法律责任。
上一篇
22 | 并发编程之Asyncio
下一篇
24 | 带你解析 Python 垃圾回收机制
 写留言

精选留言(17)

  • leixin
    2019-07-01
    老师,我曾经去某大厂面试。人家问了我几个问题,比说说,你知道元类吗?Python是如何解决循环引用的?换句话说,Python的垃圾回收机制是如何?我后来自己找了些资料看了,还是,不是理解的特别明白。老师后面的课程能帮我们讲解下吗?
    7
  • leixin
    2019-07-01
    有重要的一点没讲,GIL会在遇到io的时候自动释放,给其他线程执行的机会,这样Python多线程在io阻塞的多任务中有效。
    6
  • 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
    4
  • 小侠龙旋风
    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而言)。所以我们能够得出结论:多核下,想做并行提升效率,比较通用的方法是使用多进程,能够有效提高执行效率。
    展开
    3
  • 程序员人生
    2019-07-01
    t1 = Thread(target=CountDown, args=[n // 2]) 老师,这段代码里面n//2是什么意思?
    5
    2
  • farFlight
    2019-07-01
    另外,在测试不加锁的 foo 函数的时候,我这里循环测试10000次也不会见到n!=100的情况,这是为什么呢?
    2
  • HelloWorld
    2019-07-01
    python的单线程和多线程同时都只能利用一颗cpu核心,对于纯cpu heavy任务场景,不涉及到io耗时环节,cpu都是充分利用的,多线程和单线程相比反倒是多了线程切换的成本,所以性能反而不如单线程。
    展开
    1
  • Ben
    2019-07-05
    1. 多线程适合处理多个独立的子任务, 如果n是列表, 那么多线程/多进程可以大大减少执行时间. 但是针对单个数字n的计算, 多线程计算时变量n, 可以视为被竞争的资源, 会lock住非执行线程, GIL机制会check_interval, 强制更换为其他线程, 额外增加了执行时间. 形象来说, 就是一个人要干的活, 非要几个人一起干, 一个人干的时候. 其他人只能干看着, GIL换人时, 还要额外的时间.
    2. 每一种设计都是为了解决问题设计的, I/O慢时使用asyncio, 也就是GIL适合的场景. 但是I/O快时, 适合多线程, 此时GIL能去掉就好了
    展开
  • 汪zZ
    2019-07-05
    1.在第一例子中多线程间切换需要时间,所以多线程比单线程慢。
    2.关于思考题2,我最近在写一个GUI,左边需要实时显示摄像头,右边做一些常规处理。用的是tk,左边摄像头多线程显示,直接用多线程摄像头视频会闪,然后用tk的update更新摄像头显示,图像会稍微卡顿。
    能不能指点一下,怎么才能在界面上左边显示摄像头不卡不闪?
    展开
  • Redevil
    2019-07-03
    老师您好,问个一直想问的问题:
    假设有一个最简单的python程序,cpu-bound,比如循环print从零到10亿。
    我的机器是Linux系统,4核心CPU。
    我开两个terminal窗口,每个窗口都跑这个程序。
    这个时候两个程序会相互影响吗?这个涉及到了GIL吗?
    两个程序同时跑和只跑一个程序,执行时间上会有很大增加吗?
    谢谢!
    展开
  • 18646333118
    2019-07-02
    import sys
    a = []
    b = a
    c = b
    sys.getrefcount(a)
    结果:4
    老师请教下,在16课将的变量赋值那章说 a =b 这样相当于a指向对象,b也指向对象 。那这里为什么存在引用关系那

    展开
  • Fergus
    2019-07-01
    答1、线程切换也是消耗资源的,对于cpu-bound的任务,不停的切换过程中不能提升效率,反而消耗了更多资源;

    答2、感觉不应该取消,文中老师提到cpython的底层c不是线程安全的,如果取消,可能会提升一定的入门难度。没有受到困扰。路过ヽ(  ̄д ̄;)ノ
    展开
  • 程序员人生
    2019-07-01
    通过老师的讲解,我觉得GIL有点像java的Synchronized 监视器锁,同一时刻只有一个线程获得监视器锁。所以线程的频繁切换,会增加CPU开销,导致多线程反而速度变慢。
    展开
  • 响雨
    2019-07-01
    CPU heavy 的任务多线程还要有切换线程的消耗,所以时间比单线程还要多。
    平时工作没有用到多线程,异步执行任务都是采用celery的。
    展开
  • Hoo-Ah
    2019-07-01
    第一个问题:因为线程切换需要耗费一定的cpu资源;
    第二个问题:多个线程需要对同样的数据进行修改的时候加锁,为了提高效率,同一个线程修改的时候可以改为可重入锁。
    展开
  • 2019-07-01
    python还是属于单线程执行,cpu密集型属于大量计算,多线程反而会增加线程切换开销,效果不如单线程。 老师:有个问题,我在写flask接口登陆时,我用的selenium,分为两步,一模拟获取短信验证码,二模拟输入验证码登陆,我怎么断开这两个请求呢,防止获取验证码后用户关闭浏览器,我selenium没关闭?
    展开
  • farFlight
    2019-07-01
    第一个例子切换进程还需要时间,导致速度更慢吧
    展开