# Python GIL - CPython

# 前言

看一个例子,可以看到,执行相同的程序多线程反而比单线程更慢。

def CountDown(n):
    while n > 0:
        n -= 1
n = 100000000
CountDown(n)
# time 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()
# time 9.6s
# 由于GIL的存在只能跑满一个CPU核心,没有利用到多核
# 甚至由于线程切换的代价使得速度还不如原先

# GIL概述

  • GIL,是最流行的 Python 解释器 CPython 中的一个技术术语。它的意思是全局解释器锁,本质上是类似操作系统的 Mutex。每一个 Python 线程,在 CPython 解释器中执行时,都会先锁住自己的线程,阻止别的线程执行。

    • GIL存在于解释器级别,意思即为如果自己实现一个Python解释器,完全可以不使用GIL。
    • 任何线程执行前必须先获得GIL锁,然后每执行100条字节码(指Python2,Python3中为15毫秒),解释器就自动释放GIL锁,让别的线程有机会执行。
  • GIL(全局解释器锁)是一种机制,它限制了同一时刻只有一个线程能够执行 Python 代码。这意味着即使电脑有多个处理器核心,也无法同时执行多个 Python 线程。

    • 这种限制有助于避免多个线程同时访问共享数据时产生的问题,但它也会限制 Python 程序的并行性。

      甚至于即便有了GIL,Python线程也不一定安全。

    • CPython 会做一些小把戏,轮流执行 Python 线程。这样一来,用户看到的就是“伪并行”——Python 线程在交错执行,来模拟真正并行的线程。

    • CPython 中还有另一个机制,叫做 check_interval,意思是 CPython 解释器会去轮询检查线程 GIL 的锁住情况。每隔一段时间,Python 解释器就会强制当前线程去释放 GIL,这样别的线程才能有执行的机会。

    • 因此即使是多线程,但同一时间仍只有一个线程在执行-是“伪并行”。

  • CPython引进GIL的主要原因是:

    1. 设计者为了规避类似内存管理这样的复杂竞争风险问题(race condition);
    2. CPython大量使用C语言库,但大部分C语言库都不是线程安全的(线程安全会降低性能和增加复杂度)。
    3. GIL 的设计,主要是为了方便 CPython 解释器层面的编写者,而不是 Python 应用层面的程序员。
  • 对于IO密集型任务,Python的多线程能起到作用;但对于CPU密集型任务,Python多线程几乎占不到优势,甚至有可能因为争夺资源而变慢。

    • 在单核 CPU 环境下,多线程在执行时,线程 A 释放了 GIL 锁,那么被唤醒的线程 B 能够立即拿到 GIL 锁,线程 B 可以无缝接力继续执行
    • 而在多核CPU环境下,多线程执行时,线程 A 在 CPU0 执行完之后释放 GIL 锁,其他 CPU 上的线程都会进行竞争。但 CPU0 上的线程 B 可能又马上获取到了 GIL,这就导致其他 CPU 上被唤醒的线程,只能眼巴巴地看着 CPU0 上的线程愉快地执行着,而自己只能等待,直到又被切换到待调度的状态,这就会产生多核 CPU 频繁进行线程切换,消耗资源,这种情况也被叫做「CPU颠簸」。
    • 因此如果使用多线程运行一个CPU密集型任务,那么Python多线程是无法提高运行效率的。

# GIL工作原理-GIL和check_interval

下面这张图,就是一个 GIL 在 Python 程序的工作示例。其中,Thread 1、2、3 轮流执行,每一个线程在开始执行时,都会锁住 GIL,以阻止别的线程执行;同样的,每一个线程执行完一段后,会释放 GIL,以允许别的线程开始利用资源。

image.png

CPython 中还有另一个机制,叫做 check_interval,意思是 CPython 解释器会去轮询检查线程 GIL 的锁住情况。每隔一段时间,Python 解释器就会强制当前线程去释放 GIL,这样别的线程才能有执行的机会。

关于这个时间,早期的 Python 是 100 个 ticks,大致对应了 1000 个 bytecodes;而 Python 3 以后,interval 是 15 毫秒。

整体来说,每一个 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线程执行,但是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)

如果执行多次的话,能够发现会出现打印99或98的情况,因为n+=1这句代码让线程并不安全,我们看一下foo函数的bytecode:

>>> import dis
>>> dis.dis(foo)
LOAD_GLOBAL              0 (n)
LOAD_CONST               1 (1)
INPLACE_ADD
STORE_GLOBAL             0 (n)

​#TODO#​:
这段在3.7.16和3.10下多次运行结果都是正确的,但是从dis的情况来看 n+=1 似乎不是原子操作,因此是会发生race condition的,结果不应该一直是正确的。

这就表明,即使有了GIL我们仍然不能完全保证线程安全,仍需要时刻注意。

但与此同时,我们可以通过lock等工具,来保证线程安全。

n = 0
lock = threading.Lock()

def foo():
    global n
    with lock:
        n += 1

# 绕过GIL

  • GIL是CPython的限制,只要使用 JPython(Java 实现的 Python 解释器)等别的实现即可不再受GIL限制。
  • 很多高性能应用场景已经由C实现的Python库,他们并不通过CPython来解释执行,也就不再受GIL的限制。
  • 但如果应用真的对性能有超级严格的要求,比如 100us 就对你的应用有很大影响,那我必须要说,Python 可能不是你的最优选择。
  • 可以把关键性能(performance-critical)代码在 C++ 中实现(不再受 GIL 所限),然后再提供 Python 的调用接口。

# GIL与Python多线程

  • GIL 只支持单线程,而 Python 支持多线程
  • GIL 的存在与 Python 支持多线程并不矛盾。前面我们讲过,GIL 是指同一时刻,程序只能有一个线程运行;而 Python 中的多线程,是指多个线程交替执行,造成一个“伪并行”的结果,但是具体到某一时刻,仍然只有 1 个线程在运行,并不是真正的多线程并行。