Python 线程不等于 CPU 并行。在常见的 CPython 构建中,全局解释器锁(GIL)限制同一进程内同时执行 Python 字节码的线程数量。线程仍然很适合网络、磁盘等 I/O 密集任务,但面对纯 Python 的 CPU 密集计算,增加线程往往无法利用更多核心,甚至会因调度开销而变慢。
先判断:任务在等待,还是在计算
选择并发模型前,需要先看瓶颈在哪里:
- I/O 密集任务:HTTP 请求、数据库访问、文件读写。线程在等待 I/O 时可以让其他线程继续工作,通常适合
ThreadPoolExecutor或异步 I/O。 - 纯 Python CPU 密集任务:数值循环、解析、搜索、压缩前的数据变换。常见 CPython 环境下,优先考虑
ProcessPoolExecutor或multiprocessing。 - 原生扩展中的计算: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 服务中不要让每个请求随意创建进程池。更稳妥的做法是复用有界工作池,或把重计算交给独立任务服务。
任务粒度尤其重要。与其向进程池提交一百万个微小计算,不如把输入分块,让每次提交包含足够多的工作。这样可以减少序列化、队列和上下文切换成本。
落地时的选择清单
可以按下面的顺序做决定:
- 用分析器确认瓶颈是 CPU、I/O,还是原生库内部计算。
- I/O 密集任务先评估线程池或异步 I/O。
- 纯 Python CPU 密集任务用进程池做基准测试,并调整任务分块和工作进程数。
- 检查容器 CPU 配额,而不是只相信
os.cpu_count()返回的宿主机核心数。 - 使用 NumPy 等原生库时,确认其释放 GIL 和内部线程策略,限制 BLAS/OpenMP 线程数以避免过度订阅。
- 评估自由线程 Python 时,单独验证依赖兼容性、正确性和性能,不要把“无 GIL”当作无成本升级。
GIL 不是“Python 无法并行”的同义词。更准确的结论是:在常见 CPython 中,纯 Python 线程不适合扩展 CPU 字节码吞吐量。进程、释放 GIL 的原生代码以及经过验证的自由线程运行时都能提供其他路径,但每条路径都有自己的通信、兼容性和运维成本。