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
GIL是什么?
CPython的一个全局解释锁。
为什么存在GIL?
GIL文档中写到,GIL可以保证多线程场景中,只有一个线程在执行字节码。
这样可以避免确保在CPython源码层面是线程安全的。
假设没有了GIL,多线程运行时,引用计数就有可能出现问题。
能否去掉GIL,让Python程序应用层加锁来代替?
如果python程序多线程全部在消费者代码第一行就加锁,就可以代替GIL。(只是我的推测,没有验证过,结论不一定对。)
但是实际编写Python代码时,不可能要求所有的线程都加锁。如下的代码中,单线程和多线程也就没有区别了。
1
2
3
4
5
6lock = threading.Lock()
class Worker2(threading.Thread):
def run(self):
with lock:
....
线程和GIL
Python的线程是真线程吗?
Unix/Linux上的Python线程是真线程(Windows平台的不清楚)。是操作系统层面的线程,会受操作系统调度。
在Python/thread_pthread.h文件中,可以看到调用pthread_create函数创建系统线程。
1
2
3
4
5
6
7
8unsigned long
PyThread_start_new_thread(void (*func)(void *), void *arg)
{
pthread_t th;
...
status = pthread_create(&th, // 调用pthread_create函数创建系统线程
...既然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可以见参考文章详解。
持有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
17main_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
26static 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。
未持有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
12static 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,那是怎么做到的呢?见后面分析
怎么保证两个线程不会同时抢到GIL?
在ceval_gil.h#184行代码中,可以看到,用了一个互斥锁。
1
2
3
4
5
6
7
8
9static 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 锁
为什么CPython要保证只有一个线程在执行字节码?
换句话说,CPython中在执行字节码时,哪一部分不是线程安全的?
在CPython中引用计数的实现就不是线程安全的,为啥呢?
首先需要理解一个知识点:如果多线程场景中线程中的操作全部都是读操作,是线程安全的;如果多线程场景中线程中对同一个变量做修改操作,就不是线程安全的。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19from 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的引用计数。
CPython线程什么时候切换?
上面已经讲了一种切换方式:非持有GIL的线程过了一段时间就可以获得GIL,然后执行opcode。
除了这种,在线程等待io、调用sleep函数时,也会主动释放GIL,以便其他线程有机会获得GIL。
所以存在切换存在两种方式:主动调度和抢占式调度
1
2
3
4
5
6* 主动调度
* 等待io
* sleep
* 抢占式
* 执行N个opcode (python3.2之前)
* 过了一段时间之后 (python3.2之后)