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 的线程会在两种情况下释放它:
- 等待 I/O 时:读取文件、网络调用、
time.sleep…… 这些函数在阻塞前会释放 GIL。 - “轮次”用完时:解释器定期要求当前运行的线程释放 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 不是锁的替代品。在线程之间共享的数据仍然需要正确的同步。