Python 并发调大模型上不去,原因是默认线程池限制了并发
1497 字约 5 分钟
摘要把 asyncio 信号量调到 48 后,吞吐仍停在每分钟 70 条左右,单次请求延迟升到 32 秒。排查发现是 run_in_executor 的默认线程池在 2 核容器里只有 6 个线程。
在给招聘监控项目跑数万条岗位的初次清洗时,速度是最大的瓶颈。
单次请求带有较长的 Prompt 和 Schema 校验,本身就要几秒钟。为了尽快跑完历史存量,我开始调整并发。
当时服务商给的 TPM(Tokens Per Minute,每分钟 token 数)配额很充足,网络带宽也远够用。我先用并发 15 跑了一组,每分钟大约处理 70 条,单个请求的平均延迟在 10 秒左右。
如果吞吐随并发线性增长,外部配额又很充裕,那把并发提到 3 倍以上应该能明显提速。
我把并发参数改成了 48。
重新启动后,结果是:
- 吞吐量:从 70 条/分钟变成 75 条/分钟,几乎没有变化;
- 请求延迟:不但没有下降,中位耗时还从 10 秒涨到了 32.8 秒。
怀疑云厂商在后台排队
遇到“加大并发、延迟上升、吞吐不动”的情况,很多人的第一反应是:云厂商在后台限流。
“肯定是因为我虽然客户端发了 48 个并发,但服务商在网关层给我的 API Key 限制了实际工作槽位(Worker Slot),只给我分了十几个槽,多出来的 30 多个请求全在服务商的服务端排队呢!”
这个推论乍看很合理:
| 实验配置 | 吞吐表现 | 单请求平均耗时 | 429 限流报错 |
|---|---|---|---|
| 并发 15 | 约 70 条/分钟 | 约 10 秒 | 0 |
| 并发 48 | 约 75 条/分钟 | 32.8 秒 | 0 |
没有 429,也没有连接被拒绝,请求最终都成功返回,唯一的变化是延迟明显变长。这看起来像是服务端在排队。我一度打算就此接受现状:“算了,服务商就是这个限制,老老实实用 15 并发跑吧。”
通俗比喻:48 个人排队,前台却只有 6 个窗口为什么信号量设了 48,并发却卡在 6? 想象你去办事大厅:
- 你在外门(
asyncio.Semaphore(48))放进了 48 个人;- 但进门之后,大家要去办业务(同步阻塞 SDK),Python 默认只开了 6 个柜台(
run_in_executor按 CPU 核数只分配了 6 个线程);- 剩下的 42 个人只能在走廊里排队。
每个人在柜台办业务只需 5 秒,但排在后面的人要等前面几轮办完,总耗时就被拉长到了 30 多秒。瓶颈在柜台数量,不在门口。
逻辑自洽不等于事实排查时很容易把问题归到一个无法验证的外部原因上,比如云厂商的黑盒限流。但在拿到抓包或其他实际指标之前,这些说法都只是猜测。
找到真正的限制
推翻这个猜测的,是一次对本地运行环境的检查。
整个任务基于 Python 的 asyncio 协程。但当时用的 LLM SDK 是同步阻塞的,所以在协程里调用时,我用了常见的写法:
# 协程里调用同步阻塞 SDK
await loop.run_in_executor(None, sync_llm_client.predict, payload)外层用 asyncio.Semaphore(48) 控制最大并发数。
我原以为信号量放行 48 个协程后,就会有 48 路 TCP 连接同时发出。于是我在 sync_llm_client.predict 入口加了一个原子计数器,统计同一时刻真正在执行的线程数。结果峰值一直是:
峰值始终只有 6。
无论外层信号量设成 48、100 还是 1000,同时执行的函数数量都不超过 6 个。
为什么是 6?
run_in_executor(None, ...)会使用默认的ThreadPoolExecutor。在 Python 3.8+ 中,它的最大线程数是:min(32, (os.cpu_count() or 1) + 4)我的 Worker 跑在一个 2 核 的 Docker 容器里,所以是2 + 4 = 6。
原因找到了。48 个协程都通过了信号量,但其中 42 个在拿到线程之前,一直停在默认线程池的队列里。
外层统计的 32.8 秒并不是大模型推理慢。其中 20 多秒是这 42 个请求在容器里排队等线程,请求还没有离开本机。
让 48 路并发真正发出去
既然根因是线程池只有 6 个线程,修复就很直接:
# 不再传 None 使用默认线程池,而是显式注入专用的大线程池
executor = ThreadPoolExecutor(max_workers=64)
await loop.run_in_executor(executor, sync_llm_client.predict, payload)改完后,为了确认修复有效,我先写了一个本地的 threading.Barrier(40) 屏障测试,验证确实有 40 个工作线程能同时运行。然后再连接真实的模型 API 做 48 并发实测:
| 性能指标 | 修复前(6 线程) | 修复后(独立 64 线程池) | 变化幅度 |
|---|---|---|---|
| 配置并发数 | 48 | 48 | - |
| 底层活跃线程数 | 固定在 6 | 实际达到 48 | 约 8 倍 |
| 整体吞吐量 | 约 75 条/分钟 | 170 条/分钟 | 提升 126% |
| 请求中位延迟 | 32.8 秒 | 5.4 秒 | 下降 83% |
同样是并发 48,吞吐提高到每分钟 170 条,中位延迟也低于之前并发 15 时的数值。之前怀疑的“云厂商只给 15 个并发槽”并不成立。
真正的外部瓶颈
本地线程的限制解除后,瓶颈才转移到外部。
小规模测试通过后,批量运行到第三分钟时,控制台出现了 HTTP 429: Rate Limit Exceeded (TPM limit)。这是真正的外部限制。
这次的 429 是服务商规定的 500 万 TPM 配额被真正打满了。之后把并发调到 32,并在发起请求时加入 200ms 的随机抖动(jitter)错开时间,系统就稳定运行在配额上限附近。
总结
在 Python 异步开发中,协程和线程混用很常见,也最容易出问题:
asyncio.Semaphore不等于实际并发连接数: 它只限制协程数量。一旦下游用了run_in_executor,瓶颈就会转到线程池上;- 注意默认的全局线程池:
run_in_executor(None)在容器环境里容易成为隐患。容器配额通常只有 1 到 2 核,默认线程数会很少; - 遇到延迟异常,先查在途时间: 不要把发送耗时和排队耗时混为一谈。在关键调用前后打上时间戳,能看出问题出在本地还是远端。