Python进阶:深入GIL(下篇)

Python进阶:深入GIL(下篇)

HackPython致力于有趣有价值的编程教学

简介

有朋友吐槽,文章中太多表情,其实我加表情的初衷是避免大家阅读疲劳,既然造成了反效果,后面的内容就不会在添加表情了。

在上一篇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 <condition, mutex> 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 tim@ksr.com
  14. */
  15. # GIL的定义
  16. typedef struct {
  17. char locked; /* 0=unlocked, 1=locked */
  18. /* a <cond, mutex> 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/threadpthread.h中以及提供了PyThreadacquirelock()与PyThreadrelease_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锁的逻辑主要在PyThreadacquirelock_timed()方法中,其主要的逻辑为,如果没有获得锁,就等待,具体分为计算等待与无条件等待,与Python2不同,Python3通过计时的方式来触发「检查间隔」(check interval)机制,直到成功获取GIL,具体逻辑可以看代码中注释。

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

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


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

改进后GIL的优势

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



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



写一段简单的测试旧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并不取决于时间,而是取决于gildroprequest这一全局变量,如果gildroprequest=0,则线程会在解释器中一直运行,直到gildroprequest=1,此时线程才会释放GIL,下面同样以两个线程来解释新GIL在其中发挥的具体作用。

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



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



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

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



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



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



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



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



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

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



图中,Thread 2到了超时时间,将gildroprequest设置为了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,欢迎学习 HackPython 的教学课程并感觉您的阅读与支持。

参考文章:

发布于 2019-08-12

文章被以下专栏收录