怎样从CPython源码理解Python的GIL全局解释器锁的具体实现

访客 源码剖析 1

本文目录导读:

  1. 核心数据结构:_PyRuntimeState.gil
  2. 真正阻塞的机制:PyMutex + 条件变量
  3. 核心函数剖析:take_gil()drop_gil()
  4. 执行循环中的 GIL 检查:_PyEval_EvalFrameDefault
  5. 信号与强行切换的细节
  6. 总结 GIL 实现的三层架构
  7. 一个代码级的小实验

要深入理解 CPython 中 GIL 的具体实现,最佳路径是从源码入手,结合关键数据结构和执行流程来分析,下面我会带你从 CPython(以 3.12 版本为例,核心机制稳定)的源码角度,解析 GIL 的实现细节。

GIL 的核心目标只有一个:确保同一时刻只有一个线程执行 Python 字节码,它的实现主要围绕 ceval.cpthread(或其他线程库) 展开。

核心数据结构:_PyRuntimeState.gil

GIL 本身不是一个独立的“锁对象”,而是嵌入在 CPython 运行时状态中的一个结构体,关键位置在 Include/internal/pycore_gil.hPython/ceval_gil.h

// Include/internal/pycore_gil.h (简化)
typedef struct _gil_runtime_state {
    // 自旋锁,用于保护 GIL 内部状态(如 last_holder)的快速访问
    _Py_atomic_int locked; 
    // 当前持有 GIL 的线程的 ID (由线程库提供,如 pthread_t)
    unsigned long last_holder;
} _PyRuntime_state.gil;

关键点

  • locked 只是一个原子整数(0/1),不是互斥锁。GIL 真正的阻塞机制不在内部
  • last_holder 用于调试和性能分析,记录谁最后持有锁。

GIL 不是一个“互斥锁”锁住一个资源,而是一个 “线程所有权指示器”,线程通过一个底层条件变量来等待或通知。

真正阻塞的机制:PyMutex + 条件变量

ceval_gil.h 中,实现了 GIL 的获取和释放,底层使用了 PyThread_type_lock 这个跨平台的简单锁,但更关键的是内部使用的 struct _gil_cond

// Python/ceval_gil.c (实际结构)
struct _gil_cond {
    // 用于让等待的线程休眠,被释放时唤醒
    PyCOND_T cond;        // 通常是 pthread_cond_t
    // 保护 cond 的互斥锁,防止条件变量的竞争
    PyMUTEX_T mut;        // 通常是 pthread_mutex_t
    // 开关: 是否启用 GIL 超时切换
    int switch_interval;  // 默认 5ms
    // 记录下次强制切换的时间点
    _PyTime_t interval_end;
};

为什么需要 mutcond
因为操作系统级别的锁(pthread_mutex)不适合直接作为 GIL使用,CPython 需要支持:

  • 出让主动: 线程自己释放 GIL。
  • 抢占被动: 另一个线程可以强制当前持有者放弃 GIL(通过信号或超时)。

核心函数剖析:take_gil()drop_gil()

1 take_gil() — 尝试获取 GIL

这是每个线程执行 Python 代码前必须调用的函数,位于 Python/ceval_gil.c

流程伪码简化

static void take_gil(PyThreadState *tstate) {
    struct _gil_cond *gil_cond = &_PyRuntime.gil.cond;
    int err;
    // GIL 未被锁定,尝试原子操作锁住它
    if (!_Py_atomic_load(&_PyRuntime.gil.locked)) {
        if (/* 比较并交换成功 */) {
            // 快速路径: 直接获得 GIL,设置 last_holder
            goto done;
        }
    }
    // 慢速路径: 进入循环,等待条件变量
    while (1) {
        // 进入临界区: 锁住 mut
        PyMUTEX_LOCK(&gil_cond->mut);
        // 再次检查 GIL 是否已被释放(避免刚解锁又被其他人抢走)
        if (/* 检查 locked 并尝试获取 */) {
            // 成功获取,退出循环
            PyMUTEX_UNLOCK(&gil_cond->mut);
            break;
        }
        // 为什么要进入条件变量等待?
        // 因为当前线程需要被阻塞,直到持有者主动释放或被强制切换。
        // 设置超时: 每 5ms 唤醒一次,以检查是否需要抢占
        if (/* 超时时间未到 */) {
            // 阻塞在条件变量上,等待 signal 或广播
            err = PyCOND_WAIT(&gil_cond->cond, &gil_cond->mut, timeout);
        } else {
            // 时间到了,主动尝试抢占!
            // 这不是简单的释放,而是通过信号强制当前持有者线程放弃 GIL
            PyMUTEX_UNLOCK(&gil_cond->mut);
            // 发送信号给持有线程(通常是 SIGUSR1 或通过管道)
            _Py_gil_drop_signal();
            // 重新进入循环
            continue;
        }
    }
done:
    // 记录当前线程为持有者
    _PyRuntime.gil.last_holder = tstate->thread_id;
}

关键洞察

  • 获取 GIL 不是一个简单的 pthread_mutex_lock(),它是一个带有 自旋 + 条件变量等待 + 超时重试 的复合操作。
  • 慢路径中的 超时(一般为5ms) 是 GIL 切换的粒度,每5ms,等待线程会醒来一次,检查锁是否可用。

2 drop_gil() — 释放 GIL

这是线程执行完一段 Python 字节码后(例如遇到 I/O,或时间片用完)调用的函数。

static void drop_gil(struct _ceval_state *ceval, PyThreadState *tstate) {
    struct _gil_cond *gil_cond = &_PyRuntime.gil.cond;
    // 1. 原子设置 locked = 0
    _Py_atomic_store(&_PyRuntime.gil.locked, 0);
    // 2. 通知其他等待的线程: 锁已释放
    //    注意: 这里必须在释放 mut 之前 signal,否则可能有竞争
    PyCOND_SIGNAL(&gil_cond->cond);  // 或者 PyCOND_BROADCAST 唤醒所有
    // 3. 清除 last_holder (可选,调试用)
    _PyRuntime.gil.last_holder = 0;
}

关键洞察

  • 释放 GIL 不释放 mut 锁。condmut 是分离的。
  • 为什么需要条件变量 signal?
    因为等待的线程可能阻塞在 PyCOND_WAIT 上,如果不发 signal,它可能一直睡到超时(5ms),signal 可以使其立即醒来,减少延迟。

执行循环中的 GIL 检查:_PyEval_EvalFrameDefault

在 CPython 的解释器核心循环(Python/ceval.c 中的 _PyEval_EvalFrameDefault)中,每次执行 Python 字节码指令时,会周期性检查是否需要释放/重新获取 GIL。

关键机制CHECK_EVAL_BREAKER()DROP_GIL()

// _PyEval_EvalFrameDefault 的简化循环
for (;;) {
    // 常规字节码执行...
    // 每执行一些指令后,检查是否应该让出 GIL
    if (_Py_atomic_load(&ceval->gil_drop_request)) {
        // 有线程请求 GIL,或者时间片用完
        DROP_GIL(tstate);      // 释放 GIL
        TAKE_GIL(tstate);      // 重新获取 GIL (可能被阻塞在此)
        // 注意: 重新获取后,可能已经是另一个线程获得了锁,或者本线程继续运行
    }
    // 如果发生信号,可能强制休眠
    if (/* 信号导致切换 */) {
        // 类似处理
    }
}

gil_drop_request 标志位

  • 这不是一个简单的定时器中断,它是 一个等待线程在 take_gil() 超时后,通过信号(如 pthread_kill(SIGUSR1))设置 的全局标志。
  • 当前持有线程在检查到这个标志后,会主动释放 GIL。
  • 为什么不用信号实现强迫切换?
    因为信号不安全(信号处理函数中可以做的事有限),CPython 采用 “设置标志 → 当前线程在安全点检查 → 主动释放” 的协作式模式。

信号与强行切换的细节

当等待线程在 take_gil() 中等待了 5ms 仍未获得 GIL 时,它会发送一个 GILDropRequestSignal (通常是 SIGUSR1)。
当前持有线程会响应这个信号吗?

答案:不一定立即响应。
信号处理函数(在 Python/ceval.c 中)只是设置一个 ceval->gil_drop_request 标志,然后在下次检查点(通常每 30-100 条指令)主动释放。
这带来的影响:GIL 切换的延迟大约在 5ms(等待超时) + 几十条指令的时间,而不是立刻。

GIL 实现的三层架构

层级 组件 作用
原子标志 gil.locked 快速检查当前是否被持有
协同切换 gil_drop_request + 检查点 强制当前线程让出 GIL 的请求
条件变量+自旋 gil_cond (mut + cond) 让等待线程休眠并按时唤醒

为什么这么设计?

  • 避免忙等待(自旋)浪费 CPU。
  • 避免频繁的系统调用(如 pthread_mutex_lock 的上下文切换)。
  • 保证多线程 I/O 密集程序的响应速度(当等待超过 5ms 时,强制切换)。
  • 保证单线程性能(快速路径 if (!locked) 几乎是零开销的原子操作)。

一个代码级的小实验

你可以通过下面 Python 代码来观察 GIL 的行为(在 CPython 源码中可以用 gdb 断点 take_gildrop_gil):

import threading
import time
def busy():
    while True:
        pass  # CPU 密集型,会阻塞 GIL
t = threading.Thread(target=busy, daemon=True)
t.start()
time.sleep(1)  # 主线程会被阻塞在 GIL 之外
print("Hello")  # 可能看到较明显的延迟

在 CPython 源码级别,主线程会循环在 take_gil() 中的条件变量等待,直到被 busy 线程的 drop_gil 唤醒,或者 5ms 超时后强制发送信号。


通过上述分析,你可以理解 GIL 本质上是一个 合作式的时间片轮转机制 + 基于条件变量的等待队列,而不是一个简单的互斥锁,它的设计平衡了单线程性能与多线程公平性。

标签: CPython源码

抱歉,评论功能暂时关闭!