Python 并发实测:GIL 下多线程越并越慢,进程池 7.35 倍、asyncio 50 倍的分界线

举报
yd_243452805 发表于 2026/11/15 11:18:25 2026/11/15
【摘要】 同一段 Python 代码,换成多线程反而更慢,进程池快 7.35 倍,asyncio 快 49.79 倍。本文用 16 核实跑数据拆开三类任务的真实开销:GIL 下线程池只有 0.64x~0.76x,进程池受 626ms 启动成本约束,IO 密集场景线程与协程各自的上限在哪,最后给出一套可直接落地的选型规则。

先说结论

同样一段 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 的速度。

同样地,进程之间不共享内存,靠全局变量传递状态在进程池里必然失效。任务函数应该是纯函数:输入参数、输出结果,状态放在外部存储。做不到这一点,说明这个任务不适合拆进程。

选型规则

  1. 纯计算、单任务 > 100ms、数据量能装进内存 → 进程池,worker ≈ 核数。
  2. 网络/磁盘等待为主 → asyncio 优先;依赖库只有同步实现时用线程池,池大小按"并发等待数"设,不要按核数设。
  3. 任务粒度小于几十毫秒 → 先合批,再谈并发,调度开销会吃掉全部收益。
  4. 混合负载(解码 + 计算 + 落库)→ 用 asyncio 管 IO,把 CPU 段落用 run_in_executor 丢给进程池,别在一个池里混着跑。

并发不是加速开关,它只是把"等待"从串行时间轴上挪走。判断标准只有一个:这段代码的时间花在等,还是花在算。等,就重叠它;算,就换进程;两者都有,就分层。

【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0)

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。