Python 基础:深入了解Python中的GIL(全局解释器锁)(下篇)

Python 进阶:深入 GIL(下篇)

简介

在上一篇 GIL 的文章中,感性的了解了 GIL,本篇文章尝试从源码层面来简单解析一下 GIL,这里使用 cpython 3.7 版本的源码 (其实这块没有太大的改变,所以你看 3.5、3.6 的 Python 源码都可以),你可以直接通过 github 浏览相关部分的源码。

GIL 的定义

因为 Python 线程使用了操作系统的原生线程,这导致了多个线程同时执行容易出现竞争状态等问题,为了方便 Python 语言层面开发者的开发,就使用了 GIL(Global Interpreter Lock) 这个大锁,一口气锁住,这样开发起来就方便了,但也造成了当下 Python 运行速度慢的问题。

有人感觉 GIL 锁其实就是一个互斥锁 (Mutex lock),其实不然,GIL 的目的是让多个线程按照一定的顺序并发执行,而不是简单的保证当下时刻只有一个线程运行,这点 CPython 中也有相应的注释,而且就是在 GIL 定义之上,具体如下:

源码路径:Python/thread_pthread.h

  1. /* A pthread mutex isn't sufficient to model the Python lock type
  2. * because, according to Draft 5 of the docs (P1003.4a/D5), both of the
  3. * following are undefined:
  4. * -> a thread tries to lock a mutex it already has locked
  5. * -> a thread tries to unlock a mutex locked by a different thread
  6. * pthread mutexes are designed for serializing threads over short pieces
  7. * of code anyway, so wouldn't be an appropriate implementation of
  8. * Python's locks regardless.
  9. *
  10. * The pthread_lock struct implements a Python lock as a "locked?" bit
  11. * and a pair. In general, if the bit can be acquired
  12. * instantly, it is, else the pair is used to block the thread until the
  13. * bit is cleared. 9 May 1994 [email protected]
  14. */
  15. # GIL的定义
  16. typedef struct {
  17. char locked; /* 0=unlocked, 1=locked */
  18. /* a pair to handle an acquire of a locked lock */
  19. pthread_cond_t lock_released;
  20. pthread_mutex_t mut;
  21. } pthread_lock;

从 GIL 的定义中可知,GIL 本质是一个条件互斥组 (),其使用条件变量 lock_released 与互斥锁 mut 来保护 locked 的状态,locked 为 0 时表示未上锁,为 1 时表示线程上锁,而条件变量的引用让 GIL 可以实现多个线程按一定条件并发执行的目的。

条件变量 (condition variable) 是利用线程间共享的全局变量来控制多个线程同步的一种机制,其主要包含两个动作:

  1. 一个线程等待「条件变量的条件成立」而挂起 2. 另一个线程则是「条件成功」(即发出条件成立的信号)

在很多系统中,条件变量通常与互斥锁一同使用,目的是确保多个操作的原子性从而避免死锁的发生。

GIL 的获取与释放

从 GIL 的定义结构可以看出,线程对 GIL 的操作其实就是修过 GIL 结构中的 locked 变量的状态来达到获取或释放 GIL 的目的,在 Python/thread_pthread.h 中以及提供了 PyThread_acquire_lock() 与 PyThread_release_lock() 方法来实现线程对锁的获取与释放,先来看一下获取,代码如下:

  1. PyLockStatus
  2. PyThread_acquire_lock_timed(PyThread_type_lock lock, PY_TIMEOUT_T microseconds,
  3. int intr_flag)
  4. {
  5. PyLockStatus success = PY_LOCK_FAILURE;
  6. // GIL
  7. pthread_lock *thelock = (pthread_lock *)lock;
  8. int status, error = 0;
  9. dprintf(("PyThread_acquire_lock_timed(%p, %lld, %d) called\n",
  10. lock, microseconds, intr_flag));
  11. if (microseconds == 0) {
  12. // 获取互斥锁,从而让当前线程获得操作locked变量的权限
  13. status = pthread_mutex_trylock( &thelock->mut );
  14. if (status != EBUSY)
  15. CHECK_STATUS_PTHREAD("pthread_mutex_trylock[1]");
  16. }
  17. else {
  18. // 获取互斥锁,从而让当前线程获得操作locked变量的权限
  19. status = pthread_mutex_lock( &thelock->mut );
  20. CHECK_STATUS_PTHREAD("pthread_mutex_lock[1]");
  21. }
  22. if (status == 0) {
  23. if (thelock->locked == 0) {
  24. // 获得锁
  25. success = PY_LOCK_ACQUIRED;
  26. }
  27. else if (microseconds != 0) {
  28. struct timespec ts; // 时间
  29. if (microseconds > 0)
  30. // 等待事件
  31. MICROSECONDS_TO_TIMESPEC(microseconds, ts);
  32. /* 继续尝试,直到我们获得锁定 */
  33. //mut(互斥锁) 必须被当前线程锁定
  34. // 获得互斥锁失败,则一直尝试
  35. while (success == PY_LOCK_FAILURE) {
  36. if (microseconds > 0) {
  37. // 计时等待持有锁的线程释放锁
  38. status = pthread_cond_timedwait(
  39. &thelock->lock_released,
  40. &thelock->mut, &ts);
  41. if (status == ETIMEDOUT)
  42. break;
  43. CHECK_STATUS_PTHREAD("pthread_cond_timed_wait");
  44. }
  45. else {
  46. // 无条件等待持有锁的线程释放锁
  47. status = pthread_cond_wait(
  48. &thelock->lock_released,
  49. &thelock->mut);
  50. CHECK_STATUS_PTHREAD("pthread_cond_wait");
  51. }
  52. if (intr_flag && status == 0 && thelock->locked) {
  53. // 被唤醒了,但没有锁,则设置状态为PY_LOCK_INTR 当做异常状态来处理
  54. success = PY_LOCK_INTR;
  55. break;
  56. }
  57. else if (status == 0 && !thelock->locked) {
  58. success = PY_LOCK_ACQUIRED;
  59. }
  60. }
  61. }
  62. // 获得锁,则当前线程上说
  63. if (success == PY_LOCK_ACQUIRED) thelock->locked = 1;
  64. // 释放互斥锁,让其他线上有机会竞争获得锁
  65. status = pthread_mutex_unlock( &thelock->mut );
  66. CHECK_STATUS_PTHREAD("pthread_mutex_unlock[1]");
  67. }
  68. if (error) success = PY_LOCK_FAILURE;
  69. dprintf(("PyThread_acquire_lock_timed(%p, %lld, %d) -> %d\n",
  70. lock, microseconds, intr_flag, success));
  71. return success;
  72. }
  73. int
  74. PyThread_acquire_lock(PyThread_type_lock lock, int waitflag)
  75. {
  76. return PyThread_acquire_lock_timed(lock, waitflag ? -1 : 0, /*intr_flag=*/0);
  77. }

上述代码中使用了下面 3 个方法来操作互斥锁

  1. // 获得互斥锁
  2. pthread_mutex_lock(pthread_mutex_t *mutex);
  3. // 获得互斥锁
  4. pthread_mutex_trylock(pthread_mutex_t *mutex);
  5. // 释放互斥锁
  6. pthread_mutex_unlock(pthread_mutex_t *mutex);

这些方法会操作 POSIX 线程 (POSIX thread,简称 Pthread) 去操作锁,在 Linux、MacOS 等类 Unix 操作系统中都会使用 Pthread 作为操作系统的线程,这 3 个方法具体的细节不是本章主题,不再细究。

从上诉代码中可以看出,获取 GIL 锁的逻辑主要在 PyThread_acquire_lock_timed() 方法中,其主要的逻辑为,如果没有获得锁,就等待,具体分为计算等待与无条件等待,与 Python2 不同,Python3 通过计时的方式来触发「检查间隔」(check interval) 机制,直到成功获取 GIL,具体逻辑可以看代码中注释。

接着来看是否 GIL 锁的逻辑,即 PyThread_release_lock() 方法,代码如下:

  1. void
  2. PyThread_release_lock(PyThread_type_lock lock)
  3. {
  4. pthread_lock *thelock = (pthread_lock *)lock;
  5. int status, error = 0;
  6. (void) error; /* silence unused-but-set-variable warning */
  7. dprintf(("PyThread_release_lock(%p) called\n", lock));
  8. // 获取互斥锁,从而让当前线程操作locked变量的权限
  9. status = pthread_mutex_lock( &thelock->mut );
  10. CHECK_STATUS_PTHREAD("pthread_mutex_lock[3]");
  11. // 释放GIL,将locked置为0
  12. thelock->locked = 0;
  13. /* wake up someone (anyone, if any) waiting on the lock */
  14. // 通知其他线程当前线程已经释放GIL
  15. status = pthread_cond_signal( &thelock->lock_released );
  16. CHECK_STATUS_PTHREAD("pthread_cond_signal");
  17. // 释放互斥锁
  18. status = pthread_mutex_unlock( &thelock->mut );
  19. CHECK_STATUS_PTHREAD("pthread_mutex_unlock[3]");
  20. }

PyThread_release_lock() 方法的逻辑相对简洁,首先获取互斥锁,从而拥有操作 locked 的权限,然后就将 locked 置为 0,表示释放 GIL,接着通过 pthread_cond_signal() 方法通知其他线程「当前线程已经释放 GIL」,让其他线程去获取 GIL,其他线程其实就是在调用 pthread_cond_timedwait() 方法或 pthread_cond_wait() 方法等待的线程。

改进后 GIL 的优势

通过前面内容的讨论,已经知道 Python3.x 中并没有取消 GIL,而是将其改进,让它变得更好一些。(具体而言 Python3.2 中对 GIL 进行了改进),改进后的 GIL 相比旧 GIL(Python2.x) 会让线程对 GIL 的竞争更加平稳,下图是旧 GIL 在 2 个 CPU 下 2 个线程之间运行状态,可以发现旧 GIL 中存在着大量的 Failed GIL Acquire。

Python 基础:深入了解Python中的GIL(全局解释器锁)(下篇)_第1张图片

究其原因,是因为旧 GIL 基于 ticker 来决定是否释放 GIL(ticker 默认为 100),并且释放完后,释放的线程依旧会参与 GIL 争夺,这就使得某线程一释放 GIL 就立刻去获得它,而其他 CPU 核下的线程相当于白白被唤醒,没有抢到 GIL 后,继续挂起等待,这就造成了资源的浪费,形象如下图:

Python 基础:深入了解Python中的GIL(全局解释器锁)(下篇)_第2张图片

写一段简单的测试旧 GIL 造成的影响,在 双核 2Ghz Macbook OS-X 10.5.6 下运行

  1. def count(n):
  2. while n > 0:
  3. n -= 1

顺序执行

  1. count(100000000)
  2. count(100000000)

耗时 24.6s

多线程运行

  1. t1 = Thread(target=count,args=(100000000,))
  2. t1.start()
  3. t2 = Thread(target=count,args=(100000000,))
  4. t2.start()

耗时 45.5s,满了接近 1.8 倍,如果你在单核上运行,则耗时 38.0s,依旧比顺序执行慢,造成这么大的差距,就是因为旧 GIL 本身的设计存在问题,在多线程争夺 GIL 时有大量的资源消耗。

而改进后的 GIL 不再使用 ticker,而改为使用时间,可以通过 sys.getswitchinterval()来查看 GIL 释放的时间,默认为 5 毫秒,此外虽然说新 GIL 使用了时间,但决定线程是否释放 GIL 并不取决于时间,而是取决于 gil_drop_request 这一全局变量,如果 gil_drop_request=0,则线程会在解释器中一直运行,直到 gil_drop_request=1,此时线程才会释放 GIL,下面同样以两个线程来解释新 GIL 在其中发挥的具体作用。

首先存在两个线程,Thread 1 是正在运行的状态,Thread 2 是挂起状态。

Python 基础:深入了解Python中的GIL(全局解释器锁)(下篇)_第3张图片

Thread 2 之所以挂起,是因为 Thread 2 没有获得 GIL,它会执行 cv_wait(gil,TIMEOUT) 定时等待方法,等待一段时间 (默认 5 毫秒),直到 Thread 1 主动释放 GIL(比如 Thread 1 执行 I/O 操作时会进入休眠状态,此时它会主动释放 GIL)。

Python 基础:深入了解Python中的GIL(全局解释器锁)(下篇)_第4张图片

当 Thread 2 手动 signal 信号后,就知道 Thread 1 要休眠了,此时它就可以去获取 GIL 从而执行自身的逻辑。

另外一种情况就是,Thread 1 一直在执行,执行的时间超过了 Thread 2 cv_wait(gil,TIMEOUT) 方法等待的时间,此时 Thread 2 就会去修改全局变量 gil_drop_request,将其设置为 1,然后自己再次调用 cv_wait(gil,TIMEOUT) 挂起等待。

Python 基础:深入了解Python中的GIL(全局解释器锁)(下篇)_第5张图片

Thread 1 发现 gil_drop_request=1 会主动释放 GIL,并通过 signal 通知 Thread 2,让其获取 GIL 去运行。

Python 基础:深入了解Python中的GIL(全局解释器锁)(下篇)_第6张图片

其中需要注意的细节如下图。当 Thread 1 因为 gil_drop_request=1 要主动释放 GIL 后,会调用 cv_wait(gotgil)方法进入等待状态,该状态下的 Thread 1 会等待 Thread 2 返回的 signal 信号,从而得知另一个线程 (Thread 2) 成功获得了 GIL 并在执行状态,这就避免了多个线程争夺 GIL 的情况,从而避免了额外资源的消耗。

Python 基础:深入了解Python中的GIL(全局解释器锁)(下篇)_第7张图片

然后相同的过程会重复的发生,直到线程执行结束

Python 基础:深入了解Python中的GIL(全局解释器锁)(下篇)_第8张图片

如果存在多个线程 (大于 2 个线程),此时多个线程出现等待时间超时,此时会不会发生多个线程争夺 GIL 的情况呢?答案是不会,如下图:

Python 基础:深入了解Python中的GIL(全局解释器锁)(下篇)_第9张图片

当 Thread 1 执行时,Thread 2 等待超时了,会设置 gil_drop_request = 1,从而让 Thread 2 获得运行权限,如果此时 Thread 3 或 Thread 4 一会后也超时了,此时是不会让 Thread 2 将获得的 GIL 立即释放的,Thread 3/4 会继续在挂起状态等待一段时间。

还需要注意的一点是,设置 gil_drop_request=1 的线程并不一定会是下一个要执行的线程,下一个要执行那个线程,这取决于操作系统,直观理解如下图:

Python 基础:深入了解Python中的GIL(全局解释器锁)(下篇)_第10张图片

图中,Thread 2 到了超时时间,将 gil_drop_request 设置为了 1,但 Thread 1 发送 signal 信号的线程是 Thread 3,这造成 Thread 2 继续挂起等待,而 Thread 3 获得 GIL 执行自身逻辑。

改进后的 GIL 使用上面相同的测试代码在四核 MacPro, OS-X 10.6.2 下运行,其顺序执行时间与多线程运行时间不会有太大差距

顺序执行耗时:23.5s 双线程执行耗时:24.0s

可以看出改进后的 GIL 相比旧 GIL 已经有了比较大的性能提升。

结尾

本节从源码层面简单的讨论了 GIL,感谢您的阅读与支持。
最后,Python官方宣布,在新版的CPython中,GIL锁将成为「可选择项」,各位pythoner终于不用再受到这一限制的困扰了。
量子位推文

参考文章:

  • NewGIL
  • GIL 的实现细节

你可能感兴趣的:(python基础,python,GIL)