源码分析Python的GIL解释器锁的构造与实现

一、 背景

之前写过一篇笔记,从理论上学了了GIL解释器锁,文章地址:深入探究GIL的利与弊。当时使用的Python版本比较旧,可能与当前文章中部分源码说明有出入。

今天通过深挖源码,学习下Python底层是如何实现GIL解释器锁,分析下GIL 解释器锁的构造与应用, 探寻为什么这把GIL锁锁的是字节码。本篇笔记使用的Python版本为3.11.0 alpha 0。

二、GIL锁的构造

1.GIL结构体位于/Include/internal/pycore_gil.h头文件中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
struct _gil_runtime_state {
// Python方法占据锁的时间, 单位为微秒
unsigned long interval;
// 最后拥有锁的持有者
_Py_atomic_address last_holder;
// 标识GIL解释器锁是否被某个线程拿到, 其他线程可以通过locked字段用于判断是否具备执行权或是阻塞
_Py_atomic_int locked;
unsigned long switch_number;

// 条件变量, 用于阻塞其他线程直至GIL解释器锁被释放的条件满足
PyCOND_T cond;
// 互斥变量, 用于确保对变量的修改同步执行, 也就导致了CPython中多个线程在CPU密集情况下只能互斥执行。
PyMUTEX_T mutex;

#ifdef FORCE_SWITCHING
/* This condition variable helps the GIL-releasing thread wait for
a GIL-awaiting thread to be scheduled and take the GIL. */

// 用于阻塞释放锁的进程直到任意等待的线程拿到GIL, 并上处理机执行, 不能在释放锁后, 就不管了。
PyCOND_T switch_cond;
PyMUTEX_T switch_mutex;
#endif
};

说明:

(1). interval: 每一个Python的API所占用锁的时间,单位为微秒。

(2). last_holder: 用于记录最后持有该锁的线程,这样便于检测锁是否被其他线程拿到,然后重新调度。

(3). locked: 标识GIL解释器锁是否被某个线程拿到, 其他线程可以通过locked字段用于判断是否具备执行权或是阻塞

(4). cond: 条件变量,用于阻塞其他线程直至GIL解释器锁被释放的条件满足,常用于和mutex一起使用,实现互斥同步访问。

(5). mutex: 互斥变量, 用于确保对变量的修改同步执行。

(6). switch_cond: 等待锁重新被其他线程拿到的条件变量。

(7). switch_mutex: 用于阻塞释放锁的进程直到任意等待的线程拿到GIL, 并上处理机执行。

(8). switc_number: 用于记录运行过程中GIL解释器锁切换到不同执行线程的次数。

2.PyCOND_T结构体如下:

1
2
3
4
5
6
// 条件结构体
typedef struct _PyCOND_T
{
HANDLE sem; // 内核对象, 能够通知和被通知, 用于一个线程发送锁释放通知,其他线程可接受到通知, 去抢锁。
int waiting; /* to allow PyCOND_SIGNAL to be a no-op */
} PyCOND_T;

3.typedef CRITICAL_SECTION PyMUTEX_T; PyMUTEX_T是一个线程锁, 用于确保多线程中在临界区的互斥执行。

4.从上面的数据结构可以看到,Python底层的这把GIL解释器锁通过条件变量+”互斥量”来实现多线程中的同步互斥执行,其中的互斥量不再简单是大学操作系统书上的0或者1,而是使用CRITICAL_SECTION线程锁来充当””互斥量”, 与此同时,操作系统书上的mutex的0/1值在这里也就对应上加锁与解锁了。

5.条件变量的初始化和释放以及互斥量的初始化,加锁,解锁和释放的源码位于/Python/condvar.h头文件中,具体如下:

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
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
Py_LOCAL_INLINE(int)
PyMUTEX_INIT(PyMUTEX_T *cs)
{
InitializeCriticalSection(cs); // 初始化锁
return 0;
}

Py_LOCAL_INLINE(int)
PyMUTEX_FINI(PyMUTEX_T *cs)
{
DeleteCriticalSection(cs); // 删除锁
return 0;
}

Py_LOCAL_INLINE(int)
PyMUTEX_LOCK(PyMUTEX_T *cs)
{
EnterCriticalSection(cs); // 加锁
return 0;
}

Py_LOCAL_INLINE(int)
PyMUTEX_UNLOCK(PyMUTEX_T *cs)
{
LeaveCriticalSection(cs); // 解锁
return 0;
}


Py_LOCAL_INLINE(int)
PyCOND_INIT(PyCOND_T *cv)
{
/* A semaphore with a "large" max value, The positive value
* is only needed to catch those "lost wakeup" events and
* race conditions when a timed wait elapses.
*/
// 初始化条件变量
cv->sem = CreateSemaphore(NULL, 0, 100000, NULL);
if (cv->sem==NULL)
return -1;
cv->waiting = 0;
return 0;
}

Py_LOCAL_INLINE(int)
PyCOND_FINI(PyCOND_T *cv)
{
return CloseHandle(cv->sem) ? 0 : -1; // 关闭内核对象
}

三、 GIL锁涉及的操作集

接下来简要分析GIL相关的操作集,其实看到了这里,知道了GIL锁实际上就是由条件变量+互斥变量构造出的一把线程锁,当启动一个Python解释器,会创建一个主线程,当只有一个主线程,加不加锁并不会降太多速度,只是在按行执行每条字节码时增加了加锁和解锁的开销罢了。但是当在一个解释器进程中开辟多个线程,那么由于存在线程锁,在不会主动释放执行权的CPU密集型任务中,只能够通过执行一定量的字节码或者执行一段时间片,被迫释放执行权,由操作系统重新调度其他线程执行,这种情况下,多线程在单核CPU上由于锁的存在,被迫同步互斥执行,执行效率远不及真正的多线程并发执行,执行效率甚至可能还不如单线程,毕竟在Windows上来说线程的上下文切换要在内核完成,因此少不了内核态和用户态的频繁相互切换。

GIL锁相关的宏定义源码位于:/Python/ceval_gil.h中

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
26
27
28
29
30
31
#define MUTEX_INIT(mut) \
if (PyMUTEX_INIT(&(mut))) { \
Py_FatalError("PyMUTEX_INIT(" #mut ") failed"); };
#define MUTEX_FINI(mut) \
if (PyMUTEX_FINI(&(mut))) { \
Py_FatalError("PyMUTEX_FINI(" #mut ") failed"); };
#define MUTEX_LOCK(mut) \
if (PyMUTEX_LOCK(&(mut))) { \
Py_FatalError("PyMUTEX_LOCK(" #mut ") failed"); };
#define MUTEX_UNLOCK(mut) \
if (PyMUTEX_UNLOCK(&(mut))) { \
Py_FatalError("PyMUTEX_UNLOCK(" #mut ") failed"); };
#define COND_INIT(cond) \
if (PyCOND_INIT(&(cond))) { \
Py_FatalError("PyCOND_INIT(" #cond ") failed"); };
#define COND_SIGNAL(cond) \
if (PyCOND_SIGNAL(&(cond))) { \
Py_FatalError("PyCOND_SIGNAL(" #cond ") failed"); };
#define COND_WAIT(cond, mut) \
if (PyCOND_WAIT(&(cond), &(mut))) { \
Py_FatalError("PyCOND_WAIT(" #cond ") failed"); };
#define COND_TIMED_WAIT(cond, mut, microseconds, timeout_result) \
{ \
int r = PyCOND_TIMEDWAIT(&(cond), &(mut), (microseconds)); \
if (r < 0) \
Py_FatalError("PyCOND_WAIT(" #cond ") failed"); \
if (r) /* 1 == timeout, 2 == impl. can't say, so assume timeout */ \
timeout_result = 1; \
else \
timeout_result = 0; \
} \

说明:

1.关于锁的基本操作,C将其设定为宏定义,在一定程度上可以提高运行效率,涉及到的操作位于第二节的第3点。

四、总结

1.因为这把GIL锁的存在,Python的多线程无法充分在单核或多核上充分利用CPU资源,不过在遇到I/O事件前,线程会释放GIL锁。当然,我们可以使用多进程或者协程来在某些场景下替代多线程。ceval_gil.h头文件中还包含了一些操作集,这里就不分析了,有兴趣的朋友可以自行阅读源码,毕竟这把GIL设计在今天看来并不是Python的亮点之一。

2.最后回答下开篇提到的一个问题:为什么这把GIL锁锁的是字节码?,在Python程序执行前,解释器会将代码解释成字节码,然后Python VM将逐行读取字节码指令,获取操作码和操作数,根据操作码执行不同的函数。

3.在Python3.11.0版本中,每个线程默认占有GIL解释器锁的时间为5ms。涉及代码如下:

1
2
3
4
5
6
7
8
9
#define DEFAULT_INTERVAL 5000

static void _gil_initialize(struct _gil_runtime_state *gil)
{
// 初始化GIL
_Py_atomic_int uninitialized = {-1};
gil->locked = uninitialized;
gil->interval = DEFAULT_INTERVAL;
}
1
2
3
4
5
6
Py_LOCAL_INLINE(int)
PyCOND_TIMEDWAIT(PyCOND_T *cv, PyMUTEX_T *cs, long long us)
{
// 等待直至条件变量满足
return _PyCOND_WAIT_MS(cv, cs, (DWORD)(us/1000)); // 等待5ms
}
1
2
3
4
5
6
7
8
9
10
11
#define COND_TIMED_WAIT(cond, mut, microseconds, timeout_result) \
{ \
int r = PyCOND_TIMEDWAIT(&(cond), &(mut), (microseconds)); \
if (r < 0) \
Py_FatalError("PyCOND_WAIT(" #cond ") failed"); \
if (r) /* 1 == timeout, 2 == impl. can't say, so assume timeout */ \
timeout_result = 1; \
else \
timeout_result = 0; \
} \

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
Py_LOCAL_INLINE(int)
_PyCOND_WAIT_MS(PyCOND_T *cv, PyMUTEX_T *cs, DWORD ms)
{
DWORD wait;
cv->waiting++;
PyMUTEX_UNLOCK(cs);
// 等待锁释放的信号到来, 等待函数可使线程自愿进入等待队列,直到一个特定的内核对象cv->sem变为已通知状态为止。
// WaitForSingleObjectEx函数接受三个参数,第一个为内核对象,第二个为等待多长时间。
wait = WaitForSingleObjectEx(cv->sem, ms, FALSE);
// 该线程拿到GIL锁
PyMUTEX_LOCK(cs);
if (wait != WAIT_OBJECT_0)
// 如果在超时时间前拿到锁, 将cv->waiting减一
--cv->waiting;

if (wait == WAIT_FAILED)
return -1;
/* return 0 on success, 1 on timeout */
return wait != WAIT_OBJECT_0;
}