Loading...
墨滴

leveryd

2021/03/29  阅读:87  主题:默认主题

CPython的线程与GIL

问题背景

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

比如test.py[1]例子中,从执行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[2]文档中写到,GIL可以保证多线程场景中,只有一个线程在执行字节码。

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

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

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

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

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

    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函数创建系统线程。

    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 一行代码,会被解释成多个字节码

    >>> 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

    可以见参考文章详解。

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

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

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

    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变量。

    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。

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

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

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

    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,那是怎么做到的呢?见后面分析

  5. 怎么保证两个线程不会同时抢到GIL?

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

    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,可以做一个小结:

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

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

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

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

    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的引用计数。

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

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

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

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

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

参考

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

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

gil[5]

参考资料

[1]

test.py: https://gist.github.com/leveryd/55adf1303fa7e528cc446d4e41526604

[2]

GIL: https://wiki.python.org/moin/GlobalInterpreterLock

[3]

23 | 你真的懂Python GIL(全局解释器锁)吗?: https://time.geekbang.org/column/article/103834

[4]

Python3 源码阅读-深入了解Python GIL: https://my.oschina.net/u/4419179/blog/4305933

[5]

gil: https://github.com/zpoint/CPython-Internals/blob/master/Interpreter/gil/gil_cn.md

leveryd

2021/03/29  阅读:87  主题:默认主题

作者介绍

leveryd