Python 并发实测:GIL 下多线程越并越慢,进程池 7.35 倍、asyncio 50 倍的分界线
先说结论
同样一段 Python 代码,换成多线程可能更慢,换成多进程能快 7 倍,换成 asyncio 能快 49 倍。差别不在"并发"这个字眼上,而在于任务到底在等什么。下面这份数据来自本机实跑,不是估算。
实验环境与任务定义
16 核 CPU,Python 3.13.14(标准 GIL 构建,非 free-threaded),Windows。两类任务:
- CPU 密集:纯 Python 素数计数,
count_primes(400_000)单次约 911ms,无可释放的等待点。 - IO 密集:① 200 次 5ms 延迟等待,模拟 RPC 往返;② 200 次 64KB 小文件读取。
计时统一用 time.perf_counter(),每个用例独立进程跑一次,避免预热影响。
CPU 密集:线程池把总时间拉长了
| 任务数 | 串行 | 8 线程 | 8 进程 |
|---|---|---|---|
| 4 | 6771ms | 10656ms(0.64x) | 2144ms(3.16x) |
| 8 | 18992ms | 25097ms(0.76x) | 2583ms(7.35x) |
线程版不仅没加速,反而比串行慢 24%~36%。原因很直接:GIL 让同一时刻只有一个线程能执行字节码,而线程切换、锁竞争、check_interval 到期后的强制释放都是纯开销。任务粒度越小,这笔开销占比越高——把任务切成 64 个 count_primes(15_000) 后,8 线程只有 0.71x,比粗粒度更糟。
进程池是唯一能真正并行的方案,8 任务 7.35x。但注意 4 任务时只有 3.16x:Windows 走 spawn 启动,8 个 worker 光启动就要 626ms,接近单个任务的耗时。所以进程池的收益门槛是"单任务耗时 >> 进程启动成本",短任务用进程池只会亏。
from concurrent.futures import ThreadPoolExecutor, ProcessPoolExecutor
def cpu_job(n): # 纯计算,无 IO
...
# 对:CPU 密集用进程
with ProcessPoolExecutor(max_workers=8) as ex:
list(ex.map(cpu_job, payloads))
# 错:只多了切换开销
with ThreadPoolExecutor(max_workers=8) as ex:
list(ex.map(cpu_job, payloads))
IO 密集:线程与协程都能吃满等待时间
| 场景 | 串行 | 32 线程 | asyncio |
|---|---|---|---|
| 200 × 5ms 延迟 | 1101ms | 57.2ms(19.25x) | 22.1ms(49.79x) |
| 200 × 64KB 磁盘读 | 3497.9ms | 83.4ms(41.93x) | — |
等待期间 GIL 会被释放,所以线程能真正重叠。但线程版的上限受池大小约束:200 个任务、32 个工作线程,要排 7 批,理论下界就是 7×5ms。asyncio 把 200 个协程一次性挂到事件循环上,只有一个线程、没有上下文切换,22ms 基本就是单次延迟加调度开销,是本组数据里唯一接近理论最优的方案。
磁盘读的 41.93x 更值得注意:单线程串行读 200 个文件花了 3.5 秒,说明瓶颈在每次 open 的元数据与调度等待,这类"看起来是计算、实际在等内核"的场景同样适合线程池。
import asyncio
async def fetch(client, url):
return await client.get(url) # 必须用异步客户端
async def main():
async with AsyncClient() as c:
return await asyncio.gather(*(fetch(c, u) for u in urls))
一个常见坑:在协程里调用 requests.get() 或 time.sleep(),会把整个事件循环堵死,所有并发退化成串行,比线程池还慢。
别忽略跨进程的数据搬运成本
进程池的并行是真的,代价也是真的:参数和返回值都要经过序列化。传一个几 MB 的 DataFrame 进 worker,序列化和反序列化的时间可能超过计算本身,此时 7.35x 的加速会被吃掉大半。这也是本组实验里刻意只用整数参数的原因——想测并发,先把搬运成本剥离出去,否则测到的是 pickle 的速度。
同样地,进程之间不共享内存,靠全局变量传递状态在进程池里必然失效。任务函数应该是纯函数:输入参数、输出结果,状态放在外部存储。做不到这一点,说明这个任务不适合拆进程。
选型规则
- 纯计算、单任务 > 100ms、数据量能装进内存 → 进程池,worker ≈ 核数。
- 网络/磁盘等待为主 → asyncio 优先;依赖库只有同步实现时用线程池,池大小按"并发等待数"设,不要按核数设。
- 任务粒度小于几十毫秒 → 先合批,再谈并发,调度开销会吃掉全部收益。
- 混合负载(解码 + 计算 + 落库)→ 用 asyncio 管 IO,把 CPU 段落用
run_in_executor丢给进程池,别在一个池里混着跑。
并发不是加速开关,它只是把"等待"从串行时间轴上挪走。判断标准只有一个:这段代码的时间花在等,还是花在算。等,就重叠它;算,就换进程;两者都有,就分层。
- 点赞
- 收藏
- 关注作者
评论(0)