﻿---
title: 学习Python GIL：解决的问题&带来的限制
date: 2025-06-20
excerpt: GIL 不只是“Python 多线程跑不满多核”的原因，它首先是 CPython 在引用计数、对象模型与 C 扩展生态之间做出的工程取舍。
tags:
  - Python
  - GIL
  - Atomicity
  - Thread
  - Concurrency
cover: https://assets.vluv.space/cover/Lang/Python/GIL.webp
---

由于 GIL 的存在，Python 的多线程并不能有效利用 CPU 多核。但如果只把 GIL 看成性能包袱，就很难理解为什么 CPython 长期保留它，也很难理解为什么“去掉 GIL”会牵动整个解释器与扩展生态。

这篇文章面向已经会写 Python、也知道线程和锁的人。重点介绍：GIL 想解决什么问题，为什么“给每个对象单独加锁”听起来合理却常常更差，以及它为什么让 C 扩展更容易集成。

## GIL 是什么

**GIL（Global Interpreter Lock）** 可以直接翻译成“全局解释器锁”。在任意时刻，只有一个线程可以持有 GIL；持有 GIL 的线程才能执行 Python 字节码[^1] & 直接操作解释器状态[^2]。

```mermaid
sequenceDiagram
    autonumber
    participant T1 as Thread 1
    participant GIL
    participant T2 as Thread 2

    T1->>GIL: acquire()
    activate GIL
    Note right of T1: GIL acquired
    T1->>T1: Execute bytecode
    
    T2->>GIL: acquire()
    Note left of T2: Waiting for GIL
    
    T1->>GIL: release()
    deactivate GIL
    Note right of T1: GIL released

    activate GIL
    Note left of T2: GIL acquired
    T2->>T2: Execute bytecode
    T2->>GIL: release()
    deactivate GIL
    Note left of T2: GIL released
```

## 为什么会有 GIL

### 简化内存管理

CPython 的内存管理长期依赖 **引用计数（Reference Counting）**。每个对象头里都有一个计数器 `ob_refcnt`，记录当前有多少地方持有这个对象。

- 当有新的变量指向它时：调用 `Py_INCREF` 令计数 **+1**
- 当引用消失（变量销毁、重新赋值等）：调用 `Py_DECREF` 令计数 **-1**
- 当计数变为 **0** 时：对象立即被回收（释放内存）

```python
import sys

a = []
print(sys.getrefcount(a))  # 通常会比直觉多 1，因为 getrefcount 调用本身也持有引用
```

这个方案在实现起来相对简单。但标准 CPython 的 `Py_INCREF` 和 `Py_DECREF` 只是简单的 C 宏，直接执行 `{c} op->ob_refcnt++` 或 `--`，C 语言中的自增、自减均非原子操作，因此在多线程环境下，如果并发修改计数，可能会出现偏差，导致两种后果：

* 计数偏大：对象无法被释放，造成内存泄漏。
* 计数偏小，对象可能在仍被使用时提前释放，最终触发悬垂指针甚至段错误。

既然引用计数并发修改不安全，那就给每个对象加一把锁，或者把 `Py_INCREF` 改成原子的，不就好了吗？

直觉上，这似乎比全局一把大锁更先进，因为锁更细，看起来并发度也更高。但放到 CPython 这种“几乎一切都是对象”的运行时里，事情没有这么简单。

先看一个关键事实：**引用计数操作非常频繁，而且临界区极短。**

很多普通代码，底层都会触发 `INCREF` 或 `DECREF`：

* 变量赋值
* 函数传参与返回值
* 容器读写
* 临时对象创建与销毁
* 异常传播过程中的对象保存与清理

也就是说，如果采用对象级锁（或更细粒度的锁机制），至少需要考虑下面这些因素。

<script data-swup-reload-script type="module" src="/js/components/accordion.js"></script>
<x-accordion>
  <accordion-item title="1. 锁开销高">

引用计数更新本质上只是改对象头上的一个整数，但对象级锁获取和释放需要：

* Atomicity：被锁保护的操作必须是原子操作，即操作不可被线程中断
* Memory Visibility：要保证一个线程对对象的修改能被其他线程及时看到
* Cache Coherence：多核 CPU 中，每个核心可能有自己的缓存，对象级锁需要保证不同核心看到的对象状态一致
* 死锁避免与可重入性

  </accordion-item>

  <accordion-item title="2. 对象膨胀，缓存命中下降">

每个对象带锁会膨胀对象头，影响小整数、字符串、元组等大量小对象。

结果：

* 内存占用增加
* CPU cache 容纳热点对象减少
* 热点路径性能下降

  </accordion-item>

  <accordion-item title="3. 多对象操作复杂化">

Python 操作通常涉及多个对象，例如赋值、`{py} list.append(x)`。

问题：

* 多对象锁必须严格顺序，否则易死锁
* 许多代码路径需重写
* C 扩展也必须遵守锁协议

对象级锁不仅慢，还显著增加实现复杂度。

  </accordion-item>
</x-accordion>

CPython 选择了直接加一把全局锁，优势有：

* 引用计数更新天然串行化，不需要对象级锁
* 很多解释器内部结构可以假设“当前只有一个线程在改”
* 锁的获取与释放被摊薄到更大的执行片段上，而不是每个对象操作都来一次

## 为什么 GIL 让 C 扩展更容易集成

这部分经常只被一句话带过，但它其实是 GIL 能长期存在的关键原因之一。

CPython 的 C API 长期建立在一个重要约束上：

> 只要扩展代码持有 GIL，它就可以把大部分解释器内部状态当成“当前线程独占”的。

这意味着一个 C 扩展在操作 `PyObject*` 时，通常可以直接做这些事，而不必给每个对象再上一层锁：

* 读写引用计数
* 访问列表、字典、元组等对象的内部结构
* 创建新对象、抛出异常、调用 Python API
* 使用大量历史遗留下来的 C API 宏与快速路径

这极大降低了扩展作者的心智负担。对很多扩展来说，规则几乎可以简化成一句话：

> 碰 Python 对象之前，先确保自己持有 GIL。

这个默认前提非常重要，因为大量 C 扩展作者并不是并发运行时专家。他们可能擅长数值计算、图像处理、数据库驱动或系统接口，但并不想在每次调用 Python C API 时都重新推导对象生命周期与锁顺序。

如果没有 GIL，而是改成真正的细粒度并发模型，那么 C 扩展就不能再假设“只要进入 Python API，我就在一个近似单线程的世界里”。

这会立刻引出一串额外要求：

* 每次操作 Python 对象前，都要考虑对象是否会被其他线程同时修改
* borrowed reference（借用引用）、容器迭代器、缓存指针等历史接口会变得更脆弱
* 扩展内部如果保存了指向 Python 对象内部结构的裸指针，就必须重新审视其生命周期
* 许多旧扩展需要补锁、改数据结构、调整 API 使用方式

也就是说，去掉 GIL 不只是“解释器自己改一改”这么简单，而是 **整个 CPython C API 合同都需要收紧或重写**。

### C 扩展的并发性

GIL 并不是要求 C 扩展全程串行执行。它真正提供的是一个简单边界：

* **操作 Python 对象时，持有 GIL**
* **执行纯 C 计算或阻塞 I/O 时，可以主动释放 GIL**

这也是为什么很多扩展既能安全集成，又能在关键路径上跑出并行性。典型做法类似这样：

```c
Py_BEGIN_ALLOW_THREADS
do_blocking_io_or_native_compute();
Py_END_ALLOW_THREADS
```

这套模型的好处在于默认安全，按需放开。

扩展作者不必从第一天起就把所有代码写成细粒度线程安全，只需要在“不碰 Python 对象”的阶段显式释放 GIL。

## GIL 给 Python 多线程带来的影响

### CPU 密集型 Python 线程无法真正并行

如果两个线程都在执行纯 Python 的 CPU 密集型代码，那么它们会轮流持有 GIL，而不是同时在两个核心上并行执行字节码。

这就是为什么很多人会觉得“Python 开了多线程还是没变快”。

### I/O 密集型场景仍然适合线程

如果线程大部分时间都在等待网络、磁盘或其他阻塞 I/O，那么线程依然很有价值，因为等待阶段通常会让出 GIL。此时多线程提升的不是单核算力，而是 **等待时间的重叠**。

### 原生扩展可以绕开这一限制

如果耗时部分在 C、C++、Rust 或 Fortran 实现的原生扩展里，而且这些代码在计算阶段释放了 GIL，那么多个线程就可以在底层真正并行。

通常一些 CPU Bound 的代码会有 C 扩展实现，因此通常不需要关心 GIL 带来的影响

> [!WARNING]
>
> GIL 的存在并不意味着“写 Python 多线程代码时就自动线程安全”。GIL 保护解释器内部状态，并不保护业务变量
>
> `remain_count -= 1` 这种 read-modify-write（读、改、写）逻辑，依然可能在多个线程之间交错执行，导致结果错误。

## 如何绕过 GIL

常见思路有三种：

* **多进程**：`multiprocessing` 让每个进程拥有自己的解释器实例与 GIL，适合 CPU 密集型任务。
* **原生扩展**：把热点路径挪到 C、C++、Rust、Cython 或其他可以释放 GIL 的实现里。
* **换实现或关注 free-threaded 路线**：PEP 703 推动的是一条 no-GIL / free-threaded 的 CPython 路线，但这不是“删掉一把锁”那么简单，而是对对象模型、引用计数策略与 C 扩展兼容性的系统性改造。

## 总结

总体来看，全局锁是性能、内存、复杂度的工程折中方案，**牺牲 CPU 密集型多线程并行能力，换解释器实现复杂度、单线程路径成本与生态兼容性。**

## Read More

[Inside The Python Virtual Machine | 深入理解 Python 虚拟机](https://nanguage.gitbook.io/inside-python-vm-cn)

[^1]: Python 字节码（Bytecode）是 Python 源代码（.py）经编译后生成的一种低级、与平台无关的中间代码，存在于 `.pyc` 文件或内存中。它由 [CPython解释器](https://www.google.com/url?sa=i&source=web&rct=j&url=https://github.com/python/cpython/blob/main/Python/bytecodes.c&ved=2ahUKEwjhpJ3r1NuTAxXqI0QIHfyjMDUQy_kOegQIARAB&opi=89978449&cd&psig=AOvVaw0WwKyy027gNarNeiUzIsQw&ust=1775648653619000)在虚拟机上执行，用于提升程序加载速度。开发者可通过内置的[dis模块](https://www.google.com/url?sa=i&source=web&rct=j&url=https://docs.python.org/zh-tw/3/library/dis.html&ved=2ahUKEwjhpJ3r1NuTAxXqI0QIHfyjMDUQy_kOegQIARAC&opi=89978449&cd&psig=AOvVaw0WwKyy027gNarNeiUzIsQw&ust=1775648653619000)反编译字节码以深入理解程序运行机制。
[^2]:  Python 解释器（通常是 CPython）在运行过程中维护的所有运行时数据结构和执行环境的集合。包括对象系统（如整数、字符串等）、内存管理（引用计数与 GC）、调用栈与栈帧、线程与解释器状态、字节码执行状态（指令与操作数栈）、全局解释器锁（GIL）、模块与命名空间以及异常处理等。可参考 Python 3.7 源码中的 [PyInterpreterState结构体](https://github.com/python/cpython/blob/3.7/Include/pystate.h#L113)