绕过 GIL:让 Python 的 CPU 密集任务真正并行

2026-08-03 43 预计阅读时间: 1 分钟
来源: realpython.com AI 摘要 Original link

Disclaimer: This article is an AI-assisted summary. Read it together with the original source when precision matters. The summary may omit context, version differences, or edge cases and is not official documentation.

预计阅读时间:9 分钟

Python 线程不等于 CPU 并行。在常见的 CPython 构建中,全局解释器锁(GIL)限制同一进程内同时执行 Python 字节码的线程数量。线程仍然很适合网络、磁盘等 I/O 密集任务,但面对纯 Python 的 CPU 密集计算,增加线程往往无法利用更多核心,甚至会因调度开销而变慢。

先判断:任务在等待,还是在计算

选择并发模型前,需要先看瓶颈在哪里:

  • I/O 密集任务:HTTP 请求、数据库访问、文件读写。线程在等待 I/O 时可以让其他线程继续工作,通常适合 ThreadPoolExecutor 或异步 I/O。
  • 纯 Python CPU 密集任务:数值循环、解析、搜索、压缩前的数据变换。常见 CPython 环境下,优先考虑 ProcessPoolExecutormultiprocessing
  • 原生扩展中的计算:NumPy 等扩展可以在执行原生代码时释放 GIL;是否能并行取决于具体函数和底层库,不能仅凭“用了 C 扩展”推断。
  • 自由线程构建:较新的 Python 版本提供可选的 free-threaded 构建方向,但兼容性、依赖支持和运行开销需要按实际版本与部署环境验证,不能假设所有 CPython 都已默认取消 GIL。

关键区别是:GIL 约束的是同一解释器进程中的 Python 字节码执行。多个操作系统进程拥有各自的解释器和 GIL,因此可以同时占用多个 CPU 核心。

一组容易答错的问题

问题一:创建 8 个 Python 线程,是否必然能在 8 个核心上并行执行纯 Python 循环?

不能。在常见的带 GIL 的 CPython 中,这些线程会竞争同一个 GIL。操作系统可以把线程调度到不同核心,但这不代表它们能同时执行 Python 字节码。

问题二:GIL 是否意味着线程没有价值?

不是。线程非常适合大量时间花在等待外部资源上的任务。许多阻塞式 I/O 操作会释放 GIL,因此一个线程等待网络响应时,其他线程仍可运行。

问题三:进程池是否一定比线程池快?

也不是。启动进程、序列化参数和传输结果都有成本。任务太小、数据太大或调用太频繁时,进程通信开销可能吞掉并行收益。

问题四:调用原生库就一定绕过 GIL 吗?

不一定。扩展需要主动在安全区域释放 GIL。有些函数会释放,有些不会;还要考虑 BLAS、OpenMP 等底层库是否已经创建线程,避免进程池和原生线程叠加造成过度并行。

可以这样实践:比较串行、线程池和进程池

下面的脚本只使用标准库,执行纯 Python 的 CPU 密集循环。运行时间会随机器变化,但它可以帮助你观察三种执行方式的差异。

from __future__ import annotations

import argparse
import os
import time
from concurrent.futures import ProcessPoolExecutor, ThreadPoolExecutor
from typing import Callable


def cpu_work(iterations: int) -> int:
    value = 0
    for i in range(iterations):
        value = (value + i * i) % 1_000_000_007
    return value


def measure(label: str, run: Callable[[], list[int]]) -> list[int]:
    started = time.perf_counter()
    results = run()
    elapsed = time.perf_counter() - started
    print(f"{label:10s} {elapsed:8.3f}s  checksum={sum(results)}")
    return results


def main() -> None:
    parser = argparse.ArgumentParser()
    parser.add_argument("--tasks", type=int, default=8)
    parser.add_argument("--iterations", type=int, default=2_000_000)
    parser.add_argument("--workers", type=int, default=min(4, os.cpu_count() or 1))
    args = parser.parse_args()

    inputs = [args.iterations] * args.tasks

    serial = measure("serial", lambda: [cpu_work(n) for n in inputs])

    def run_threads() -> list[int]:
        with ThreadPoolExecutor(max_workers=args.workers) as executor:
            return list(executor.map(cpu_work, inputs))

    def run_processes() -> list[int]:
        with ProcessPoolExecutor(max_workers=args.workers) as executor:
            return list(executor.map(cpu_work, inputs))

    threaded = measure("threads", run_threads)
    processed = measure("processes", run_processes)

    assert serial == threaded == processed


if __name__ == "__main__":
    main()

将代码保存为 gil_benchmark.py 后运行:

python gil_benchmark.py --tasks 8 --iterations 2000000 --workers 4

如果单个任务完成得太快,可以把 --iterations 调高。这个脚本必须保留 if __name__ == "__main__" 入口保护,尤其是在使用 spawn 启动进程的平台上,否则子进程导入模块时可能重复创建进程。

不要把一次运行当成严谨结论。可以预热后多跑几次,同时观察 CPU 利用率,并确保机器没有受到其他负载干扰。虚拟机的 CPU 配额、容器限制和节能策略也会影响结果。

进程并行的工程成本

进程池解决了 GIL 对纯 Python 字节码的限制,但也改变了数据和状态的边界:

  • 传入进程池的函数和参数通常需要可序列化;局部闭包、打开的连接和锁对象经常无法直接传递。
  • 每个进程拥有独立内存。复制大型对象会增加内存占用和序列化时间。
  • 全局变量不会成为可靠的跨进程共享状态。共享数据应使用消息、队列、共享内存或外部存储,并明确同步规则。
  • 异常会跨进程传回调用方,但日志、取消和超时仍需单独设计。
  • Web 服务中不要让每个请求随意创建进程池。更稳妥的做法是复用有界工作池,或把重计算交给独立任务服务。

任务粒度尤其重要。与其向进程池提交一百万个微小计算,不如把输入分块,让每次提交包含足够多的工作。这样可以减少序列化、队列和上下文切换成本。

落地时的选择清单

可以按下面的顺序做决定:

  1. 用分析器确认瓶颈是 CPU、I/O,还是原生库内部计算。
  2. I/O 密集任务先评估线程池或异步 I/O。
  3. 纯 Python CPU 密集任务用进程池做基准测试,并调整任务分块和工作进程数。
  4. 检查容器 CPU 配额,而不是只相信 os.cpu_count() 返回的宿主机核心数。
  5. 使用 NumPy 等原生库时,确认其释放 GIL 和内部线程策略,限制 BLAS/OpenMP 线程数以避免过度订阅。
  6. 评估自由线程 Python 时,单独验证依赖兼容性、正确性和性能,不要把“无 GIL”当作无成本升级。

GIL 不是“Python 无法并行”的同义词。更准确的结论是:在常见 CPython 中,纯 Python 线程不适合扩展 CPU 字节码吞吐量。进程、释放 GIL 的原生代码以及经过验证的自由线程运行时都能提供其他路径,但每条路径都有自己的通信、兼容性和运维成本。


相关推荐