CPython的线程与GIL

问题背景

CPython程序的GIL会导致在CPU密集型任务中,多线程并不能提高运算速度。

比如test.py例子中,从执行test2函数和test1函数时间对比可以看出来,多线程并没有比单线程跑得更快。

本文主要学习总结GIL、线程和GIL的关系,回答以下问题:

  • GIL
    • GIL是什么?
    • 为什么存在GIL?
    • 能否去掉GIL,让Python程序应用层加锁来代替GIL的作用?
  • 线程和GIL的关系
    • Python的线程是真线程吗?
    • 既然GIL能保证只有一个线程在执行字节码,为什么我们用Python多线程时还需要考虑线程安全?
    • 持有GIL的线程什么时候释放GIL?
    • 未持有GIL的线程什么时候申请GIL?
    • 怎么保证两个线程不会同时抢到GIL?
    • 为什么CPython要保证只有一个线程在执行字节码?
    • CPython线程什么时候切换?

分析的CPython是3.8.0版本,commit id是fa919fdf2583bdfead1df00e842f24f30b2a34bf

分析过程

GIL

  1. GIL是什么?

    CPython的一个全局解释锁。

  2. 为什么存在GIL?

    GIL文档中写到,GIL可以保证多线程场景中,只有一个线程在执行字节码。

    这样可以避免确保在CPython源码层面是线程安全的。

    假设没有了GIL,多线程运行时,引用计数就有可能出现问题。

  3. 能否去掉GIL,让Python程序应用层加锁来代替?

    如果python程序多线程全部在消费者代码第一行就加锁,就可以代替GIL。(只是我的推测,没有验证过,结论不一定对。)

    但是实际编写Python代码时,不可能要求所有的线程都加锁。如下的代码中,单线程和多线程也就没有区别了。

    1
    2
    3
    4
    5
    6
    lock = threading.Lock()

    class Worker2(threading.Thread):
    def run(self):
    with lock:
    ....

线程和GIL

  1. Python的线程是真线程吗?

    Unix/Linux上的Python线程是真线程(Windows平台的不清楚)。是操作系统层面的线程,会受操作系统调度。

    在Python/thread_pthread.h文件中,可以看到调用pthread_create函数创建系统线程。

    1
    2
    3
    4
    5
    6
    7
    8
    unsigned long
    PyThread_start_new_thread(void (*func)(void *), void *arg)
    {
    pthread_t th;
    ...

    status = pthread_create(&th, // 调用pthread_create函数创建系统线程
    ...
  2. 既然GIL能保证只有一个线程在执行字节码,为什么我们用Python多线程时还需要考虑线程安全?

    因为一行Python代码,可能会包含多个字节码。GIL可以保证字节码执行时是线程安全的,但是无法保证每一行Python代码是线程安全的。

    比如 n=n+1 一行代码,会被解释成多个字节码

    1
    2
    3
    4
    5
    6
    7
    8
    >>> import dis
    >>> dis.dis("n=n+1")
    1 0 LOAD_NAME 0 (n)
    2 LOAD_CONST 0 (1)
    4 BINARY_ADD
    6 STORE_NAME 0 (n)
    8 LOAD_CONST 1 (None)
    10 RETURN_VALUE

    可以见参考文章详解。

  1. 持有GIL的线程什么时候释放GIL?

    先说结论:如果未持有GIL的线程太久没有获取GIL,已经持有GIL的线程就会释放锁。

    在ceval.c#1237行中可以看到:ceval->gil_drop_request 被标记时,会释放锁。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    main_loop:
    for (;;) {
    ...

    if (_Py_atomic_load_relaxed(&ceval->gil_drop_request)) { // 其他线程要求当前线程释放锁
    /* Give another thread a chance */
    if (_PyThreadState_Swap(&runtime->gilstate, NULL) != tstate) {
    Py_FatalError("ceval: tstate mix-up");
    }
    drop_gil(ceval, tstate); // 当前线程释放锁

    /* Other threads may run now */

    take_gil(ceval, tstate); // 当前线程尝试再获取锁

    ...
    }

    eval->gil_drop_request 什么时候被标记呢?

    看下面的代码可以得出结论:如果 未持有GIL的线程 超过了等待时间 且 GIL是锁住的状态 且 GIL的持有者没有变更过,就会标记 ceval->gil_drop_request变量。

    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
    static void take_gil(PyThreadState *tstate)
    {
    ...
    while (_Py_atomic_load_relaxed(&_PyRuntime.ceval.gil.locked)) { // 只要 gil 是锁住的状态, 进入这个循环
    int timed_out = 0;
    unsigned long saved_switchnum;

    saved_switchnum = _PyRuntime.ceval.gil.switch_number; // 保存当前持有GIL的身份

    // 释放 gil.mutex, 并在以下两种条件下唤醒
    // 1. 等待 INTERVAL 微秒(默认 5000)
    // 2. 还没有等待到 5000 微秒但是收到了 gil.cond 的信号

    COND_TIMED_WAIT(_PyRuntime.ceval.gil.cond, _PyRuntime.ceval.gil.mutex,
    INTERVAL, timed_out);

    // 如果超过了等待时间 && GIL是锁住的状态 && GIL的持有者没有变更过
    if (timed_out &&
    _Py_atomic_load_relaxed(&_PyRuntime.ceval.gil.locked) &&
    _PyRuntime.ceval.gil.switch_number == saved_switchnum) {

    SET_GIL_DROP_REQUEST(); // 把 gil_drop_request 值设为 1, 持有GIL的线程看到这个值的时候, 会尝试释放GIL
    }
    }
    ...
    }

    所以,当未持有GIL的线程太久没有获取GIL了,就会设置一个全局变量。当前持有GIL的线程看到全局变量后,就知道自己要释放GIL。

  2. 未持有GIL的线程什么时候申请GIL?

    先说结论:正在持有GIL的线程释放后,未持有GIL的线程可以获得GIL。

    申请GIL的代码在Python/ceval_gil.h#take_gil中,可以看到 未持有GIL的线程 通过while循环 来等GIL释放。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    static void
    take_gil(struct _ceval_runtime_state *ceval, PyThreadState *tstate)
    {
    ...
    while (_Py_atomic_load_relaxed(&gil->locked)) { // gil 不是锁住状态时跳出循环
    ...
    }
    _ready:
    ...
    /* We now hold the GIL */
    _Py_atomic_store_relaxed(&gil->locked, 1); // 抢到了锁
    _Py_ANNOTATE_RWLOCK_ACQUIRED(&gil->locked, /*is_write=*/1);
这里我有一个疑问:上面的代码中,如果多个线程同时跳出while循环,就会出现多个线程同时抢到GIL。

我的疑问肯定是不对的,两个线程肯定不会同时抢到GIL,那是怎么做到的呢?见后面分析
  1. 怎么保证两个线程不会同时抢到GIL?

    在ceval_gil.h#184行代码中,可以看到,用了一个互斥锁。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    static void
    take_gil(struct _ceval_runtime_state *ceval, PyThreadState *tstate)
    {
    ...
    struct _gil_runtime_state *gil = &ceval->gil;
    int err = errno;
    MUTEX_LOCK(gil->mutex); // gil->mutex是互斥锁
    ...
    MUTEX_UNLOCK(gil->mutex);

    这样在多个线程都调用take_gil来申请GIL时,只有一个线程能够跳出while循环申请到GIL。

现在已经知道了什么时候释放和申请GIL,可以做一个小结:

1
2
3
4
* 已经获得GIL的线程在执行字节码前,如果有其他线程要求释放GIL,它就会释放GIL。
* 没有获得GIL的线程可以分为两种情况:
* 有一个线程可以要求"已经获得GIL的线程"释放GIL,并且循环等待释放GIL
* 其他线程在等 gil->mutex 锁
  1. 为什么CPython要保证只有一个线程在执行字节码?

    换句话说,CPython中在执行字节码时,哪一部分不是线程安全的?

    在CPython中引用计数的实现就不是线程安全的,为啥呢?

    首先需要理解一个知识点:如果多线程场景中线程中的操作全部都是读操作,是线程安全的;如果多线程场景中线程中对同一个变量做修改操作,就不是线程安全的。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    from threading import Thread

    n = 1

    def CountDown(_):
    print(n) # 仅仅是打印n,是线程安全的
    # n += 1 # 修改n的值,这里是非线程安全

    def test():
    t1 = Thread(target=CountDown, args=[1])
    t2 = Thread(target=CountDown, args=[1])
    t1.start()
    t2.start()
    t1.join()
    t2.join()


    if __name__ == "__main__":
    test()

    从写Python脚本的角度看:上面的Python代码中,我们不加锁是没有问题的。

    从CPython角度看:在上面代码中,CPython在多线程中修改变量n的引用计数时,就会有”多线程对同一个变量做修改操作”,这个变量就是n的引用计数。这里就不是线程安全的,所以要加GIL。加了GIL后,只会有一个线程在修改n的引用计数。

  2. CPython线程什么时候切换?

    上面已经讲了一种切换方式:非持有GIL的线程过了一段时间就可以获得GIL,然后执行opcode。

    除了这种,在线程等待io、调用sleep函数时,也会主动释放GIL,以便其他线程有机会获得GIL。

    所以存在切换存在两种方式:主动调度和抢占式调度

    1
    2
    3
    4
    5
    6
    * 主动调度
    * 等待io
    * sleep
    * 抢占式
    * 执行N个opcode (python3.2之前)
    * 过了一段时间之后 (python3.2之后)

参考

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

Python3 源码阅读-深入了解Python GIL

gil