Python 中的 GIL 是什么及其对性能的影响

1. 总览

许多从 Java 或 Go 切换到 Python 的开发者会遇到同一个问题。在 8 核机器上运行 4 个线程,但程序并没有变快,有时甚至还会变慢。罪魁祸首通常是 GIL(全局解释器锁)。

本文解释了 GIL 是什么以及为什么它存在。你将测量 GIL 对 CPU 密集型代码和 I/O 密集型代码的影响。文章最后,你将知道何时应该使用 threading,何时使用 multiprocessing,以及 Python 3.13+ 的无 GIL 版本改变了什么。

2. GIL 是什么?

GIL 是 CPython(最常见的 Python 实现)中的一个互斥锁。在任何时刻,在一个进程内,只有一个线程能执行 Python 字节码。

你仍然可以创建多个线程。这些线程仍然是操作系统的真实线程。但要执行字节码,线程必须持有 GIL。其他线程必须等待。

2.1. CPython 为什么需要 GIL?

主要原因是 引用计数 的内存管理机制。每个对象都有一个计数器,表示有多少个地方引用了它:

import sys

order = {"id": 1, "total": 250_000}
print(sys.getrefcount(order))

alias = order
print(sys.getrefcount(order))
2
3

sys.getrefcount 返回引用计数。结果总是比实际多 1,因为函数调用本身也会创建一个临时引用。当你执行 alias = order 时,计数增加 1。

如果两个线程同时增加或减少这个计数器而没有锁,计数可能出错。后果是内存泄漏,或者更糟,对象在仍有使用者的情况下被释放。在全局放一个锁是保护所有对象的最简单方法。它也使得编写 C 扩展更容易,并让单线程代码运行更快。

2.2. 线程何时释放 GIL?

持有 GIL 的线程会在两种情况下释放它:

  1. 等待 I/O 时:读取文件、网络调用、time.sleep…… 这些函数在阻塞前会释放 GIL。
  2. “轮次”用完时:解释器定期要求当前运行的线程释放 GIL,以让其他线程有机会运行。

你可以查看每个轮次的时间长度:

import sys

print(sys.getswitchinterval())
0.005

默认是 5 毫秒。你可以用 sys.setswitchinterval() 改变它,但几乎不需要这样做。

3. 对 CPU 密集型代码的影响

让我们测试一下。下面的函数计算素数,是纯计算工作:

import time
from concurrent.futures import ThreadPoolExecutor


def count_primes(limit: int) -> int:
    count = 0
    for n in range(2, limit):
        if all(n % d for d in range(2, int(n ** 0.5) + 1)):
            count += 1
    return count


def run_sequential(jobs: list[int]) -> float:
    start = time.perf_counter()
    for job in jobs:
        count_primes(job)
    return time.perf_counter() - start


def run_threads(jobs: list[int]) -> float:
    start = time.perf_counter()
    with ThreadPoolExecutor(max_workers=4) as pool:
        list(pool.map(count_primes, jobs))
    return time.perf_counter() - start


if __name__ == "__main__":
    jobs = [200_000] * 4
    print(f"顺序执行: {run_sequential(jobs):.2f}s")
    print(f"4 个线程: {run_threads(jobs):.2f}s")

run_sequential 依次运行 4 个任务。run_threads 将 4 个任务分配给 4 个线程。在 8 核机器上的结果(仅供参考):

顺序执行: 2.31s
4 个线程: 2.40s

4 个线程并没有更快。实际上甚至还慢了一点。由于 GIL,线程轮流在一个 CPU 核心上运行。额外的慢部分是争夺 GIL 和上下文切换的开销。

注意: 这是最常见的误解。向纯 Python 计算代码添加线程到 CPython 的 GIL 不会 让它更快。

4. 对 I/O 密集型代码的影响

对于主要等待 I/O 的代码,情况完全不同。假设我们调用一个远程 PaymentService,需要 0.5 秒:

import time
from concurrent.futures import ThreadPoolExecutor


def charge_payment(order_id: int) -> str:
    time.sleep(0.5)  # 模拟调用 HTTP 支付网关
    return f"order-{order_id}: OK"


if __name__ == "__main__":
    order_ids = list(range(10))

    start = time.perf_counter()
    for oid in order_ids:
        charge_payment(oid)
    print(f"顺序执行  : {time.perf_counter() - start:.2f}s")

    start = time.perf_counter()
    with ThreadPoolExecutor(max_workers=10) as pool:
        list(pool.map(charge_payment, order_ids))
    print(f"10 个线程: {time.perf_counter() - start:.2f}s")
顺序执行  : 5.01s
10 个线程: 0.50s

快了 10 倍。time.sleep,以及 socket 和文件读取,在等待期间会释放 GIL。10 个线程可以并行等待。它们只需在短暂时刻获得 GIL 来处理结果。

结论:Python 中的线程对于 I/O 密集型任务仍然非常有用,例如调用 API、查询数据库或下载文件。对于大量 I/O,asyncio 也是一个好选择。

5. 使用 multiprocessing 突破 GIL

要为计算充分利用多个核心,标准方法是使用多个进程。每个进程都有自己的解释器和自己的 GIL。

import time
from concurrent.futures import ProcessPoolExecutor


def count_primes(limit: int) -> int:
    count = 0
    for n in range(2, limit):
        if all(n % d for d in range(2, int(n ** 0.5) + 1)):
            count += 1
    return count


if __name__ == "__main__":
    jobs = [200_000] * 4
    start = time.perf_counter()
    with ProcessPoolExecutor(max_workers=4) as pool:
        results = list(pool.map(count_primes, jobs))
    print(results)
    print(f"4 个进程: {time.perf_counter() - start:.2f}s")
[17984, 17984, 17984, 17984]
4 个进程: 0.68s

与顺序执行的 2.31 秒相比,我们快了大约 3.4 倍。没有达到整整 4 倍是因为进程创建的开销。

if __name__ == "__main__": 块是必需的。在 Windows 和 macOS 上,子进程通过重新 import 模块来创建。缺少这个块,子进程会再次创建 pool,程序会出错。

注意: Multiprocessing 有其自身的成本。参数和结果必须通过 pickle 序列化才能在进程之间传输。如果你发送一个很大的对象,比如几百 MB 的 DataFrame,去做一个小任务,复制成本可能大于收益。Lambda 和嵌套函数也无法被 pickle。

6. C 扩展可以释放 GIL

许多用 C 编写的库会在运行繁重部分时主动释放 GIL,例如 NumPy、hashlib 或 zlib。在这种情况下,线程仍然可以真正并行运行:

import hashlib
import os
import time
from concurrent.futures import ThreadPoolExecutor

payloads = [os.urandom(50_000_000) for _ in range(4)]


def digest(data: bytes) -> str:
    return hashlib.sha256(data).hexdigest()


if __name__ == "__main__":
    start = time.perf_counter()
    for p in payloads:
        digest(p)
    print(f"顺序执行 : {time.perf_counter() - start:.2f}s")

    start = time.perf_counter()
    with ThreadPoolExecutor(max_workers=4) as pool:
        list(pool.map(digest, payloads))
    print(f"4 个线程: {time.perf_counter() - start:.2f}s")
顺序执行 : 0.98s
4 个线程: 0.27s

hashlib 在哈希大数据时(从大约 2 KB 开始)会释放 GIL。因此 4 个线程可以在 4 个核心上运行。实际上,GIL 只阻止 Python 字节码,不阻止库的原生代码。

7. GIL 不会让你的代码线程安全

很多人认为“既然有 GIL,就不需要锁”。这是错的。GIL 保护解释器的内部状态,不保护你的逻辑。线程可能在任何两条语句之间被切换出去。

import threading
import time


class BankAccount:
    def __init__(self) -> None:
        self.balance = 0
        self._lock = threading.Lock()

    def deposit_unsafe(self, amount: int) -> None:
        current = self.balance
        time.sleep(0)  # 释放 GIL,模拟一个 I/O 操作的中断
        self.balance = current + amount

    def deposit_safe(self, amount: int) -> None:
        with self._lock:
            current = self.balance
            time.sleep(0)
            self.balance = current + amount


def run(method_name: str, threads: int = 10, times: int = 100) -> int:
    account = BankAccount()
    method = getattr(account, method_name)

    def worker() -> None:
        for _ in range(times):
            method(1)

    pool = [threading.Thread(target=worker) for _ in range(threads)]
    for t in pool:
        t.start()
    for t in pool:
        t.join()
    return account.balance


if __name__ == "__main__":
    print("unsafe:", run("deposit_unsafe"))
    print("safe  :", run("deposit_safe"))
unsafe: 112
safe  : 1000

我们期望 10 × 100 = 1000。在 deposit_unsafe 中,多个线程读取同一个旧的 current 值,然后互相覆盖,所以大部分存款丢失了。time.sleep(0) 帮助错误确定地显现出来。在真实代码中,释放 GIL 的地方可能是一条日志或数据库查询。deposit_safe 使用 threading.Lock 使读-修改-写操作成为原子的。

我们可以用 pytest 编写测试来锁定这个行为。安装:

pip install pytest
# test_bank_account.py
from bank_account import run


def test_safe_deposit_keeps_correct_balance():
    assert run("deposit_safe", threads=8, times=200) == 1600


def test_unsafe_deposit_loses_updates():
    assert run("deposit_unsafe", threads=8, times=200) < 1600
pytest -q test_bank_account.py
..                                                       [100%]
2 passed in 0.41s

第二个测试证明即使 CPython 有 GIL,竞态条件确实存在。

8. Python 无 GIL:无 GIL 的未来

从 Python 3.13 开始,CPython 有了无 GIL 的版本(PEP 703),允许禁用 GIL。通常你通过 python3.13t 或 python3.14t 命令运行这个版本。在 Python 3.14 中,此版本已获得官方支持(PEP 779),但仍不是默认的。

检查运行时的 GIL 状态:

import sys
import sysconfig

print("Build 支持无 GIL:", bool(sysconfig.get_config_var("Py_GIL_DISABLED")))
print("GIL 启用:", sys._is_gil_enabled())

使用无 GIL 版本运行:

python3.14t check_gil.py
Build 支持无 GIL: True
GIL 启用: False

有了这个版本,第 3 部分中的 count_primes 例子使用 ThreadPoolExecutor 会真正利用多个核心。

不过,在切换之前,你需要考虑几点:

  • 单线程代码会慢一点,因为解释器必须使用更复杂的同步机制。
  • C 扩展必须兼容。 如果你 import 一个还未声明支持无 GIL 的扩展,CPython 会自动重新启用 GIL 并打印警告。你可以通过 PYTHON_GIL=0 强制,但你需要自己承担风险。
  • 竞态条件更容易发生。 例如第 7 部分的例子不再被“只有一个线程在任何时间运行”所“隐藏”。

9. 选择哪种工具?

工作类型 应使用
I/O 密集型(HTTP、DB、文件) threading、ThreadPoolExecutor、asyncio
纯 Python CPU 密集型 ProcessPoolExecutor / multiprocessing
NumPy、hashlib 等上的 CPU 密集型 线程仍可用,因为库释放 GIL
CPU 密集型、需要共享内存、库已兼容 考虑 Python 无 GIL 版本(3.13t/3.14t)

注意: 不要凭感觉优化。使用 time.perf_counter() 或 cProfile 先测量。很多“因 GIL 而慢”的程序实际上是因为算法或数据库查询而慢。

10. 结论

GIL 是 CPython 的全局锁。它确保在任何时刻只有一个线程执行 Python 字节码,主要是为了保护引用计数。

实际后果包括四点:

  • 线程不能加速纯 Python CPU 密集型代码。
  • 线程仍然对 I/O 密集型和支持释放 GIL 的库很有效。
  • 要为计算充分利用多个核心,使用 multiprocessing,或尝试 Python 3.13+ 的无 GIL 版本。
  • GIL 不是锁的替代品。在线程之间共享的数据仍然需要正确的同步。
pythongilconcurrencymultithreadingperformance