本文是David Beazley 在chipy上演讲的ppt, 深入的介绍了GIL的相关表现及原理, 此文是在简单翻译的基础上加上了一些其他地方查看的资料组合而成
先让我们做一个实验
如下是一个CPU-bound的方法
def countdown(n):
while n > 0:
n -= 1
COUNT = 100000000
countdown(COUNT)
现在, 我将这个任务分给两个线程
t1 = Thread(target=countdown, args=(COUNT//2,))
t2 = Thread(target=countdown, args=(COUNT//2,))
t1.start()
t2.start()
t1.join()
t2.join()
实验数据如下
在一台四核的MBP上
顺序的执行(一个线程): 7.8s
两个线程并发: 15.4(慢了两倍。。)四个线程并发: 15.7s(基本上一样)
限制了四核CPU中的三核, 只有一核在工作:
两个线程 : 11.3s
四个线程: 11.6s
python 线程
python 的线程是真实系统的线程, 主要分为两类
- POSIX threads(pthreads)
- Windows threads
这些线程完全由操作系统管理(线程分配和线程切换),并且由python解释器进程执行
python在创建子线程的时候发生的事情:
- python 创建了一个小的数据结构, 去保存一些python 解释器的状态
- 一个新的pthread加载进来
- 这个新的线程被称作 PyEval_CallObject
每一个线程都有自己独特的解释器数据结构(PyThreadState)
- 当前的栈帧
- 当前递归深度
- 线程id
- 一些进程错误信息
- 可选的一些 跟踪信息 概述信息 处理异常 的hooks
这是一个小的 C数据结构(< 100 bytes)
GIL 简介
GIL有如下特点
- 并发的执行是被禁止的
- 只有一个GIL
- GIL保证了在一个时刻在python解释器上只有一个线程
- GIL简化了很多底层的细节(比如内存管理, C程序的扩展维护等等)
python线程运行模型
你可以使用GIL进行合作式的多任务处理
当前线程只有获取到GIL才可以运行
每次GIL的获取和释放都会释放I/O
针对CPU Bound任务
针对CPU Bound 线程 ,会在每100 “ticks”的时候进行一次check
你可以使用sys.setcheckinterval()进行检查
什么是tick
tick离散的与编译器指令关联
指令存在于python VM
这个tick与计时无关, 有可能很长
周期性的check
这个check是为了让各个线程平均利用CPU时间
周期性的检查十分简单,列一下步骤
针对当前运行的线程
- 重置 tick 计数器
- 如果是主进程, 运行一个信号处理器
- 释放GIL锁
- 重新获取GIL锁
C 实现
/*python/ceval.c*/
/*Decrement ticks*/
if (--_Py_Ticker < 0){
/*reset ticks*/
_Py_Ticker = _Py_CheckInterval;
/*run signal handlers*/
if (things_to_do){
if (Py_MakePendingCalls() < 0){
...
}
}
if (interpreter_lock){
/*give another thread a chance*/
PyThread_release_lock(interpreter_lock);
/*Other threads may run now*/
PyThread_acquire_lock(interpreter_lock, 1)
}
}
这里有两个大的问题
- CPU-bound线程表现出的性能惩罚是什么原因导致的?
- GIL 的获取和释放是否是线程自己负责的吗?
python locks
python 解释器只提供了一个锁类型(在C中)这个锁用于构建所有其他线程的同步单元
这个锁不是一个简单的互斥锁, 它是由 pthread 互斥锁和条件变量组成的二进制信号
至于为什么用pthread, 按照python社区的说法是,操作系统本身的线程调度已经非常成熟稳定了,没有必要自己搞一套。所以Python的线程就是C语言的一个pthread,并通过操作系统调度算法进行调度(例如linux是CFS)。
GIL 是这个lock的一个实例
python锁构成
python 的锁是由三部分构成
locked = 0 # 锁状态
mutex = pthreads_mutex() # 状态的锁
cond = pthreads_cond() # 用作唤醒和等待
release() {
mutex.acquire()
locked = 0
mutex.release()
cond.signal()
}
acquire(){
mutex.acquire()
while(locked){
cond.wait(mutex)
}
locked = 1
mutex.release()
}
graph LR
cond.signal()-->cond.wait(mutex)
线程切换
假设现在有两个线程
- thread 1 运行
- thread 2 就绪状态, 等待GIL
1.简单的场景
thread 1 执行IO操作(read/write), 这时tread 1会被阻塞,然后释放GIL,释放GIL会引发一个信号操作, 这个信号操作被线程库和操作系统处理,切换上下文, 然后thread 2 获取到GIL,运行
2.复杂的场景
thread 1 一直运行到了check,(100ticks), 这时 thread 1 释放了GIL,并且发出了signal, 但是这时,thread 1和 thread 2都有可能运行,如何判定?
pthread的秘密
> 条件变量有一个内部的等待队列,cond.wait() 的时候thread进入队列, cond.signal()的时候thread弹出队列,遵循FIFO原则
在弹出thread之后进度操作系统调度,操作系统维护一个就绪状态的进程/线程的优先队列, 被signaled 的thread进入这个优先队列,之后操作系统根据各个进程/线程的优先级来运行队列,运行的既可能是被signaled队列,也有可能不是
所以, 当thread 1 release() 的时候, thread 1 和 thread 2 都从cond 队列中弹出,进入了操作系统维护的优先级队列, 这时需要看谁的优先级高, 谁有可能优先获取到执行权
GIL Instrumentation
为了更加详细的学习线程调度, 我用一些log去测试python, 这些log记录了大量的GIL采集, 释放,冲突,重试等等的痕迹, 目的就是为了更好的了解线程的分配调度,线程之间的交互,以及GIL的内部行为
这里我们加了另一个tick 计数器,来检查check间隔的周期数
release(){
mutex.acquire()
locked = 0
if gil: log("release")
mutex.release()
cv.signal()
}
# 实际在C中, 所有的log事件都会完整的保存到内存中, 直到进程结束(意味着 没有IO)
acquire(){
mutex.acquire()
if locked and gil:
log("busy")
while locked:
cv.wait(mutex)
if locked and gil:
log("retry")
locked = 1
if gil: log("acquire")
mutex.release()
}
thread id | tick countdown | total number of “checks” executed | log info |
---|---|---|---|
t2 | 100 | 5351 | ACQUIRE |
t2 | 100 | 5352 | RELEASE |
t2 | 100 | 5352 | ACQUIRE |
t2 | 100 | 5353 | RELEASE |
t1 | 100 | 5353 | ACQUIRE |
t2 | 38 | 5353 | BUSY |
t1 | 100 | 5354 | RELEASE |
t1 | 100 | 5354 | ACQUIRE |
t2 | 79 | 5354 | RETRY |
t1 | 100 | 5355 | RELEASE |
t1 | 100 | 5355 | ACQUIRE |
t2 | 73 | 5355 | RETRY |
ACQUIRE: 获取 GIL
RELEASE: 释放 GIL
BUSY:尝试去获取GIL, 但是被占用
RETRY: 重复获取GIL, 但是还是被占用
当只有一个CPU的情况下
- 线程交替进行运行, 但是线程的交替远没有你想象的频繁
- 在好的情况下, 每100次或者1000次check才会发生一次线程上下文切换
当有多个CPU的情况下。。。
- 可以调度运行的线程同时(在不同的cores上)对同一个GIL进行竞争
假设一种情况 thread 1 在CPU 1上运行 & thread 2 在CPU 2上运行, thread 1 正在拿着GIL运行中,
- thread 2 反复的被signaled, 但是,当它被唤醒的时候, GIL早就被thread 1 重新获取到了
多核事件处理
- CPU-bound的线程使得GIL难以处理 想要处理事件 的线程
IO事件处理
- 其实IO操作并不经常堵塞,
- 由于缓存的存在, OS能够实现即时的IO访问, 并且保持线程running
- 但是, GIL是会释放的
- 这就导致了GIL在重负荷下的颠簸
以上是python2.* 版本的GIL的表现, 自从python 3.2 版本之后, 实现了一个新的GIL,详情等什么时候用到再看吧
参考:
http://www.dabeaz.com/python/UnderstandingGIL.pdf
http://www.dabeaz.com/python/GIL.pdf
http://cenalulu.github.io/python/gil-in-python/