
前阵子排查一个 Python 接口。单次请求只调用一次旧 SDK,耗时五六十毫秒,压测并发一上来却像排队点名:第一批返回以后第二批才动,吞吐几乎没有增长。
接口已经写成 async def,调用方也用了 await。最初大家盯着连接池、网关和下游限流看了一圈,最后问题只是协程中间夹着一次普通的阻塞调用。
async 不会把函数体里的每一行自动变成非阻塞。事件循环只有在协程真正交还控制权时,才能去推进别的任务。
最小复现很短:同时创建 50 个任务,每个任务等待 50 ms。三组代码分别使用 time.sleep(0.05)、await asyncio.sleep(0.05),以及把同一个阻塞函数交给默认线程池。
async def direct_blocking():
time.sleep(0.05)
async def cooperative_wait():
await asyncio.sleep(0.05)
async def thread_offload(loop):
await loop.run_in_executor(None, blocking_io)

第一组看起来最荒唐,可线上代码通常不会直接写一行 time.sleep。它可能藏在同步 HTTP 客户端、数据库驱动、云厂商 SDK、文件读写或一次 DNS 查询里。外层函数即使是协程,只要调用栈走到这些同步等待,运行该事件循环的线程仍会停在那里。
确认方法也不复杂。我先在可疑调用前后记录单调时钟,再给事件循环放一个固定间隔的心跳任务。若业务调用期间心跳也一起延迟,就不必先猜下游是不是慢;事件循环线程本身已经没有调度机会。
实验在 Windows 上的 Python 3.7.0 运行,每组 50 个任务,共跑 5 轮。直调阻塞函数的中位总耗时是 3.094287 秒,P95 为 3.102048 秒。理论上 50 乘 50 ms 是 2.5 秒,实际更长主要受这台机器上短时 sleep 的计时粒度影响;关键不在绝对值,而在 50 次等待基本串起来了。
协作式 asyncio.sleep 的中位耗时为 0.062620 秒,P95 为 0.063206 秒;线程池转移的中位耗时为 0.064977 秒,P95 为 0.108310 秒。相对直调阻塞组,中位耗时分别快约 49.41 倍和 47.62 倍。

这张图不能证明“线程池一定有五十倍收益”。实验故意选择纯等待任务,没有计算、连接池和下游容量限制,只验证一件事:把阻塞等待放在事件循环线程上,会让本来可以重叠的时间排成一列。
线程池组的 P95 比协作等待明显更高,也符合预期。它多了一次任务提交和线程调度,默认线程池的工作线程数还有限。若库本身提供可靠的异步接口,优先使用原生异步调用;run_in_executor 更适合隔离暂时无法替换的同步边界,不是给整个函数贴上的性能补丁。
现在常见写法是 await asyncio.to_thread(...)。这次复现实验第一次运行时直接报错,因为当前环境是 Python 3.7.0,asyncio.to_thread 尚不存在。最后改成兼容写法:取得正在运行的 loop,再调用 run_in_executor。
这次小插曲也提醒我,不要看到某段正确的新版本示例就原样塞回旧服务。修阻塞问题以前至少确认三件事:生产 Python 版本、同步库是否线程安全、上下文变量是否需要传递。线程池里的函数若依赖线程本地状态,迁移后可能得到性能提升和新的数据错误。
我还会检查超时与取消。协程取消并不等于已经在线程里运行的同步函数立刻停止。接口层可能已经返回超时,后台线程仍在占连接、写文件或等待下游。若调用本身没有可控超时,简单移进线程池只会把堵塞从事件循环搬到工作线程队列。
定位到旧 SDK 后,我没有只改压测命中的那个函数。项目里搜索它的同步客户端类型,又找到日志归档和批量补偿两个路径;它们平时流量小,所以一直没有显出排队形状。随后给异步入口补了规则:同步 I/O 必须在边界处显式标记,代码评审时看到 async def 内的普通客户端就继续追到底。
监控也加了事件循环延迟。接口 P95 只能说明用户已经变慢,loop lag 更接近原因:定时心跳本该每 100 ms 运行,实际晚了多少。它若和请求并发一起抬升,优先检查阻塞调用或长时间不让出控制权的 CPU 循环。
我还在测试里放了一条很小的并发断言:十个短等待任务的总耗时不能接近十次单任务耗时之和。它不承担性能基准,只负责在有人把异步客户端换回同步实现时尽早报警。计时断言容易受机器负载影响,所以阈值留得很宽,并单独保留可重复运行的诊断脚本;前者守调用方式,后者才用来观察具体数字。
CPU 密集任务要另看。把纯 Python 计算交给默认线程池,通常无法像 I/O 等待那样得到并行收益,还可能让事件循环和线程一起争资源。那类任务需要拆小主动让出、使用进程池,或移到独立任务系统,取决于延迟和部署约束。
连接池也必须同时观察。若线程池允许三十个同步请求并发执行,下游连接却只有十个,二十个线程会改在连接池入口等待。接口看上去不再卡事件循环,整体延迟仍可能没有改善。我的验收指标因此不只看总耗时,还一起记录事件循环延迟、线程池队列长度、下游活跃连接和超时数;只有等待从事件循环移走且没有在下一层失控堆积,修复才算真正成立。
最后保留的判断顺序很朴素:先证明事件循环被卡住,再定位哪一段没有 await 的实际让出;能换原生异步库就换,暂时不能换才放进受控线程池;最后用并发压测观察 loop lag、工作线程、下游连接和取消后的残留任务。
函数前面的 async 只说明它可以被等待。系统能不能并发,还是要看它在等待时有没有真的把路让开。

文章问答
她会先读这篇文章,再安静地回答你的问题。可以继续追问。
加载中…
登录后参与讨论