Python GIL 全局解释器锁:你被它坑过多少次?

技术分享 0 次阅读
Python GIL 全局解释器锁:你被它坑过多少次?

开了 8 个线程,结果跑得比单线程还慢——这不是 Python 的 Bug,而是 GIL 在 "保护" 你。

从一个场景说起

你写了一个计算密集型的程序:对一百万个 URL 做 MD5 哈希计算,按道理开多线程应该能快几倍,对吧?

import hashlib
import time
from threading import Thread

# 模拟 CPU 密集型任务:计算哈希
TARGETS = [f"data-{i}".encode() for i in range(5_000_000)]

def compute_hashes(data_chunk):
    """计算一批数据的 MD5 哈希值"""
    for data in data_chunk:
        hashlib.md5(data).hexdigest()
    print(f"计算完成,处理了 {len(data_chunk)} 条数据")

def single_thread():
    """单线程版本"""
    start = time.perf_counter()
    compute_hashes(TARGETS)
    print(f"单线程耗时: {time.perf_counter() - start:.2f}s")

def multi_thread():
    """多线程版本:试图利用多核加速"""
    chunk_size = len(TARGETS) // 4
    threads = []
    start = time.perf_counter()

    for i in range(4):
        chunk = TARGETS[i * chunk_size : (i + 1) * chunk_size]
        t = Thread(target=compute_hashes, args=(chunk,))
        threads.append(t)
        t.start()

    for t in threads:
        t.join()

    print(f"4 线程耗时: {time.perf_counter() - start:.2f}s")

if __name__ == "__main__":
    single_thread()
    multi_thread()

你满怀期待地运行,结果却让人大跌眼镜:

计算完成,处理了 5000000 条数据
单线程耗时: 10.23s
计算完成,处理了 1250000 条数据
计算完成,处理了 1250000 条数据
计算完成,处理了 1250000 条数据
计算完成,处理了 1250000 条数据
4 线程耗时: 12.87s     ← 比单线程还慢?!

不仅没加速,反而更慢了。这时候你可能听过一个 "传言":Python 的多线程是假的。这背后的根本原因就是——GIL(Global Interpreter Lock,全局解释器锁)。

核心原理

GIL 到底是什么?

GIL 是 CPython(最主流的 Python 实现)解释器内部的一个互斥锁(mutex)。它的作用是——任何时候,只允许一个线程执行 Python 字节码

你可以把它想象成一把 "方向盘钥匙":

  • 多核 CPU 就像一辆有四个方向盘的车(理论上可以多人同时操控)
  • 但 Python 的 "车内设计" 限制了——只有一把钥匙,谁拿到谁开
  • 其他线程即使有座位(核),也只能干等
# 这段代码背后的字节码,每次只能由一个线程执行
x = x + 1    # 看似一行,实际涉及 3 个操作

dis 模块拆开看看:

import dis

def increment():
    x = 0
    x = x + 1
    return x

dis.dis(increment)

输出:

  5           0 LOAD_CONST               1 (0)
              2 STORE_FAST               0 (x)

  6           4 LOAD_FAST                0 (x)     # 1. 读取 x
              6 LOAD_CONST               2 (1)     # 2. 加载常量 1
              8 BINARY_OP                13 (+=)   # 3. 相加
             10 STORE_FAST               0 (x)     # 4. 写回 x

  7          12 LOAD_FAST                0 (x)
             14 RETURN_VALUE

就这一行 x = x + 1,CPython 执行了4 步字节码。如果没有 GIL,两个线程同时执行这段代码,就可能出现经典的 "读取-修改-写入" 竞争条件。

为什么 GIL 是非存在不可的?

这就得从 CPython 的内存管理说起了。CPython 使用引用计数(reference counting) 来管理内存:

// CPython 源码层面的伪代码示意
typedef struct {
    PyObject_HEAD        // 包含 ob_refcnt(引用计数)
    // ... 其他字段
} PyObject;

// 增加引用
void Py_INCREF(PyObject *obj) {
    obj->ob_refcnt++;    // ← 非原子操作!
}

// 减少引用
void Py_DECREF(PyObject *obj) {
    obj->ob_refcnt--;
    if (obj->ob_refcnt == 0) {
        // 回收对象
    }
}

关键问题来了ob_refcnt++ob_refcnt-- 在 C 语言层面是非原子操作。在没有 GIL 的多线程环境下,两个线程同时对一个对象执行 Py_DECREF,引用计数可能只减了一次而非两次,导致对象永远无法被回收——这就是所谓的内存泄漏。

GIL 用最简单粗暴的方式解决了这个问题:同一时间只让一个线程跑,引用计数自然就不会乱

历史背景:1992 年 Guido van Rossum 加入 GIL 时,大多数 PC 是单核 CPU,多线程并发安全不是一个优先问题。这个设计在当时是合理的——简单、正确、易于维护。但这个 "历史债务" 一直背到了今天。

GIL 的切换机制

CPython 中 GIL 的释放和获取是定时切换的。一个线程持有 GIL 时,会在以下情况下释放:

  1. 每执行 100 条字节码指令(sys.setcheckinterval 控制,Python 3.2 前)
  2. 每运行约 5 毫秒(Python 3.2 后,改为了基于时间的切换)
  3. 遇到 I/O 操作时主动释放(如 socket.recv()time.sleep()、文件读写)
  4. C 扩展代码手动释放(如 NumPy 在大量计算时会释放 GIL)

Python 3.2 之后采用了更公平的机制,简化示意如下:

# 以下为 GIL 切换的简化逻辑(非真实源码)
import threading
import time

class GIL:
    """模拟 CPython 的 GIL 切换逻辑 (Python 3.2 后的基于时间)"""
    def __init__(self):
        self._lock = threading.Lock()
        self._holder = None
        self._switch_interval = 0.005  # 5 毫秒

    def acquire(self, thread_id):
        while True:
            if self._lock.acquire(blocking=False):
                self._holder = thread_id
                return True
            # 让出 CPU,给持有者机会释放
            time.sleep(0)

    def release(self, thread_id):
        if self._holder == thread_id:
            self._holder = None
            self._lock.release()

当一个线程持有 GIL 满了约 5ms 后,解释器会设置一个请求标志,当前线程检查到标志后主动释放 GIL,让其他线程有机会执行。这就是为什么多线程即使跑在 8 核 CPU 上,同一时刻也只有 1 个核在工作

深入细节

CPU 密集型 vs I/O 密集型的本质差异

很多人听说过 "GIL 只影响 CPU 密集型任务",这句话的真相是什么?来看对比实验:

实验一:CPU 密集型(纯计算)

import time
import threading

def cpu_intensive(n):
    """纯粹的 CPU 计算:没有等待,没有 I/O"""
    result = 0
    for i in range(n):
        result += i ** 2
    return result

def test_cpu():
    N = 50_000_000

    # 单线程
    start = time.perf_counter()
    cpu_intensive(N)
    single_time = time.perf_counter() - start
    print(f"CPU 单线程: {single_time:.2f}s")

    # 多线程
    start = time.perf_counter()
    threads = [
        threading.Thread(target=cpu_intensive, args=(N // 4,))
        for _ in range(4)
    ]
    for t in threads: t.start()
    for t in threads: t.join()
    multi_time = time.perf_counter() - start
    print(f"CPU 4 线程: {multi_time:.2f}s")
    print(f"加速比: {single_time / multi_time:.2f}x")

预期结果(在多核机器上):

CPU 单线程: 8.42s
CPU 4 线程: 11.65s      ← 甚至更慢!
加速比: 0.72x

实验二:I/O 密集型(网络请求)

import time
import threading
import urllib.request

URLS = [
    "https://httpbin.org/delay/1",   # 延迟 1 秒
    "https://httpbin.org/delay/1",
    "https://httpbin.org/delay/1",
    "https://httpbin.org/delay/1",
]

def io_intensive(url):
    """I/O 密集型:等待网络响应"""
    with urllib.request.urlopen(url) as resp:
        return len(resp.read())

def test_io():
    # 单线程
    start = time.perf_counter()
    for url in URLS:
        io_intensive(url)
    single_time = time.perf_counter() - start
    print(f"I/O 单线程: {single_time:.2f}s")

    # 多线程
    start = time.perf_counter()
    threads = [threading.Thread(target=io_intensive, args=(url,)) for url in URLS]
    for t in threads: t.start()
    for t in threads: t.join()
    multi_time = time.perf_counter() - start
    print(f"I/O 4 线程: {multi_time:.2f}s")
    print(f"加速比: {single_time / multi_time:.2f}x")

预期结果:

I/O 单线程: 4.12s
I/O 4 线程: 1.05s       ← 接近 4 倍加速!
加速比: 3.92x

为什么 I/O 密集型能加速?

因为当线程执行 urlopen() 等 I/O 操作时,CPython 会主动释放 GIL,让其他线程可以执行。每个线程大部分时间都在等待网络响应,而非争抢 GIL。GIL 不再是瓶颈。

画一张时序对比图:

CPU 密集型(时间轴 →)
T1 [====GIL====] [====GIL====] ...
T2  [等待GIL]  [====GIL====] ...
                    ↑ 大量时间花在争抢锁上

I/O 密集型(时间轴 →)
T1 [计算][---I/O等待(释放GIL)---][计算]...
T2       [计算][---I/O等待(释放GIL)---]...
         ↑ 等待期间其他线程可以执行

GIL 与多进程 (multiprocessing)

既然多线程在 CPU 密集型任务上不给力,Python 提供了 multiprocessing 模块——每个进程有独立的解释器和独立的 GIL,从而实现真正的并行。

import time
import multiprocessing as mp

def cpu_intensive(n):
    result = 0
    for i in range(n):
        result += i ** 2
    return result

def test_multiprocess():
    N = 50_000_000
    start = time.perf_counter()

    with mp.Pool(4) as pool:
        # 将任务分到 4 个进程,每个进程有独立的 GIL
        pool.map(cpu_intensive, [N // 4] * 4)

    multi_time = time.perf_counter() - start
    print(f"多进程 (4 进程): {multi_time:.2f}s")
    # 在多核机器上 ≈ 2.1s(接近 4 倍加速)

if __name__ == "__main__":
    test_multiprocess()

但这也有代价:

  • 进程间通信(IPC) 比线程间通信开销大得多(需要 pickle 序列化)
  • 内存不共享:修改全局变量需要显式使用 QueueValue
  • 创建进程 比创建线程开销大

Python 3.13 的 "自由线程" 革命

2023 年发布的 PEP 703(Making the Global Interpreter Lock Optional in CPython)由 Sam Gross 提出,在 Python 3.13 中首次引入了 --disable-gil 编译选项,允许运行所谓的 "自由线程(free-threaded)" Python。

关键变化:

方面 有 GIL 无 GIL (自由线程)
引用计数 非原子操作 偏向引用计数 (biased refcount)
内存分配器 pymalloc mimalloc(线程安全)
垃圾回收 简单直接 延迟引用计数 + 停止世界
性能(单线程) 基准线 ~5-10% 开销
性能(多线程 CPU) 无加速 接近线性加速

前面我们有 GIL 版本跑了 10.23s 的 MD5 哈希计算,换成自由线程 Python 3.13t:

无 GIL 版本 (3.13t) - 4 线程: 2.85s     ← 约 3.6 倍加速

不过要注意,截至 2026 年 7 月,自由线程模式仍然是实验性的,并非默认选项。Python 3.14 计划将其变为非实验性但仍可选,预计到 2028 年左右才会默认禁用 GIL。

最佳实践

了解了 GIL 的本质,在实际项目中你应该怎么应对?

规则一:区分任务类型,选对并发工具

任务类型 推荐方案 原因
CPU 密集型 multiprocessing.Pool / concurrent.futures.ProcessPoolExecutor 避开 GIL,利用多核
I/O 密集型 threading / asyncio GIL 在 I/O 时释放,线程/协程够用
混合型 分流:CPU 部分用多进程 + I/O 部分用协程 最大化资源利用率

规则二:对于 CPU 密集操作,优先考虑 asyncio + 回调

有时候你不需要真正的并行,而是需要并发 + 非阻塞。比如 Web 服务中处理请求:

import asyncio
import hashlib

async def handle_request(data: bytes):
    """模拟 Web 请求处理"""
    # CPU 操作:哈希计算 —— 用 run_in_executor 放到线程池
    loop = asyncio.get_event_loop()
    result = await loop.run_in_executor(
        None, hashlib.sha256, data
    )
    return result.hexdigest()

async def main():
    tasks = [handle_request(b"data") for _ in range(1000)]
    results = await asyncio.gather(*tasks)
    print(f"处理完成 {len(results)} 个请求")

asyncio.run(main())

run_in_executor 默认使用 ThreadPoolExecutor,对于会释放 GIL 的操作(如 hashlib 底层 C 实现会释放 GIL)非常有效。

规则三:如果必须用多线程 + 共享状态,使用细粒度锁

GIL 只保证字节码级别的安全,不保证业务逻辑的安全。多个线程操作同一个 listdict 时,即使有 GIL,你也可能遇到逻辑竞态:

import threading

counter = 0

def bad_increment():
    global counter
    for _ in range(100_000):
        counter += 1  # 有 GIL 保护字节码,但仍有竞态?

threads = [threading.Thread(target=bad_increment) for _ in range(10)]
for t in threads: t.start()
for t in threads: t.join()
print(counter)  # 可能不等于 1_000_000!

这是因为 counter += 1 虽然被 GIL 保护了单条字节码,但操作切换发生在字节码之间。解决方案很简单——用 threading.Lock

lock = threading.Lock()
counter = 0

def safe_increment():
    global counter
    for _ in range(100_000):
        with lock:          # 显式锁,保证读取-修改-写入的原子性
            counter += 1

# 输出:1000000 ← 正确

规则四:使用 C 扩展时,善用 Py_BEGIN_ALLOW_THREADS

如果你在写 C 扩展,记得在长时间计算时释放 GIL:

// C 扩展示例:在不需要访问 Python 对象的计算中释放 GIL
static PyObject* heavy_compute(PyObject* self, PyObject* args) {
    long n;
    if (!PyArg_ParseTuple(args, "l", &n))
        return NULL;

    double result = 0;

    // 释放 GIL —— 允许其他 Python 线程执行
    Py_BEGIN_ALLOW_THREADS

    for (long i = 0; i < n; i++) {
        result += sin(i) * cos(i);  // 纯 C 计算
    }

    // 重新获取 GIL
    Py_END_ALLOW_THREADS

    return PyFloat_FromDouble(result);
}

NumPy、Pandas 等科学计算库就是通过这种方式,在底层数组计算时释放 GIL,从而实现多线程加速。

总结

  1. GIL 是 CPython 的 "历史遗产"——它为了引用计数的线程安全而设计,确保了 Python 内存管理的简单与正确,代价是 CPU 密集型多线程程序无法利用多核。
  2. GIL 不是 Python 语言的特性,而是 CPython 的实现细节——Jython 和 IronPython 就没有 GIL。Python 3.13+ 提供了 --disable-gil 的自由线程选项,正在逐步走向无 GIL 时代。
  3. 应对 GIL 的最佳武器是 "认清它的边界"——I/O 密集型任务大胆用多线程,CPU 密集型任务用多进程或 asyncio + executor,需要极致性能时考虑 C 扩展或 Rust(PyO3)。

延伸阅读

  • PEP 703 – Making the Global Interpreter Lock Optional in CPython:了解无 GIL 的完整设计与取舍
  • David Beazley 的 GIL 演讲 – "Understanding the Python GIL":对 GIL 内部机制的深层可视化讲解
  • 《CPython Internals》 (Anthony Shaw):如果你想深入 CPython 源码层面的 GIL 实现细节