← 中文资源

简体中文版本

Python — 多线程 CPU 任务并不比单线程快

解释 threading.Thread 对 CPU 密集 Python 代码为何无加速,用计时量化 GIL 影响,并切换到多进程或原生扩展。

阅读英文原文

症状

  征兆在挂钟时间里,而不是错误信息中。同样的 8 个任务跑三种方式:串行 12.4 秒、8 线程 12.6 秒(没有更好甚至更差)、8 进程 1.7 秒(硬件一直是就绪的)。线程既不帮忙也不捣乱,就指认了 GIL 之下的纯 Python CPU 工作:top 里一个核打满、其余核空闲。对照实验是 I/O 密集负载,同一个线程池会展现出预期的 8 倍重叠——两个实验的差异本身就是诊断结论。

诊断思路

  先做三向基准:串行、线程池、进程池,同一输入同一机器;再用 mpstat 或 htop 逐核视图观察线程运行时的核占用形态,单核饱和配空闲兄弟核就是串行化的可视化;最后用 cProfile 按 cumtime 排序分类负载构成——累计时间集中在纯 Python 帧(循环、字典操作)线程无解,集中在 numpy/zlib 等 C 调用里则另当别论。偶发的“提速”只可能来自 I/O 等待或释放 GIL 的 C 扩展,那会掩盖结构性上限。

根因

  CPython 在全局解释器锁(GIL)下执行字节码。多个 Python 线程不能同时执行字节码,CPU 密集的纯 Python 工作无论开多少线程都被串行化,线程切换开销常常还让线程池比朴素循环略慢。

修复与验证

  按负载类别选工具:CPU 密集的 Python 用 ProcessPoolExecutor 或 multiprocessing;热点循环下沉到 GIL 之下——NumPy ufunc、Cython 的 nogil 块、PyO3 的 Rust 扩展在 C 层执行期间会释放锁;I/O 并发继续用线程。混合负载先 cProfile 分类再动手。验证时用相同输入跑齐三种形态记录表格:进程池加速接近核数线性、线程池保持平坦——两者合起来证明被测变量正是 GIL 边界。再跑一个 NumPy 占主导的变体展示例外情形(释放 GIL 的 C 代码里线程确实能并行),让团队学会用测量而非传闻来给负载分类。

代码示例

serial            12.4s
threads (8)       12.6s   <- no better, maybe worse
processes (8)      1.7s   <- the hardware was always ready
from concurrent.futures import ProcessPoolExecutor

with ProcessPoolExecutor(max_workers=8) as pool:
    results = list(pool.map(cpu_task, work_items))