Python asyncio高并发编程的六大陷阱与性能优化实践
Python asyncio高并发编程的六大陷阱与性能优化实践
Python 的 asyncio 让单线程处理上万并发连接成为可能,很多团队因此把爬虫、网关、消息消费等 IO 密集型服务迁到了异步框架上。但上线后经常发现:明明用了 async/await,QPS 却上不去,CPU 单核跑满、事件循环阻塞告警频发,甚至比同步版本还慢。问题几乎都出在「异步代码写得像同步代码」上。本文结合生产排障经验,梳理六个最常见陷阱与对应优化手段。
一、在协程里调用阻塞函数
这是最致命的一类问题。事件循环是单线程的,一旦某个协程内部执行了同步阻塞操作(requests.get()、time.sleep()、大文件读写、同步数据库驱动),整个循环都会被卡住,所有并发任务瞬间退化为串行。正确做法是把阻塞调用交给线程池:await loop.run_in_executor(None, blocking_fn, arg);或者直接换用异步库(aiohttp、httpx.AsyncClient、asyncpg)。排查方法:开启 asyncio 的 debug 模式,事件循环会把执行超过 100ms 的回调打印出来。
二、忘记 await,协程从未执行
调用一个 async def 函数只创建协程对象,不写 await 就永远不会执行,而且不会报错,只是「什么都没发生」。这类 bug 在日志里表现为「明明调了接口却没有请求记录」。除了严格 code review,可以在 CI 中加入 flake8-async 或 pylint 的 unawaited 检查规则,把问题拦在提测前。
三、CPU 密集任务挤占事件循环
JSON 大对象解析、图片压缩、加密签名、复杂正则匹配,都是典型的 CPU 密集操作。它们在协程里执行同样会阻塞循环。解决方案是按任务性质分流:CPU 密集任务交给 ProcessPoolExecutor(绕开 GIL),IO 密集任务才留在事件循环内。实践中常见错误是用 ThreadPoolExecutor 承载 CPU 任务,结果因 GIL 争抢导致整体更慢。
四、并发无边界导致资源耗尽
asyncio.gather(*[fetch(u) for u in urls]) 这种写法在 urls 有几万条时会一次性创建几万个任务,内存暴涨、文件描述符耗尽、对端服务被打挂。必须用 asyncio.Semaphore 限制并发度,或使用 asyncio.as_completed 配合分批提交。生产上建议把「并发上限」做成可配置参数,按下游承载能力动态调整。
五、异常被静默吞掉
用 gather 时若未设置 return_exceptions=True,任一任务抛异常会立刻向上传播,其余任务仍继续运行但结果无人回收;而用 create_task 创建的任务如果没保存引用,异常只会在 GC 时以「Task exception was never retrieved」警告形式出现,非常容易漏掉。建议统一封装任务提交函数:保存任务引用、注册 add_done_callback 记录异常、并在退出前 await asyncio.gather(..., return_exceptions=True) 统一收敛。
六、超时与取消处理不当
异步代码里没有超时等于把故障放大:一个卡住的下游会拖住整个协程链。应给每个外部调用加 asyncio.wait_for(coro, timeout=3) 或使用 asyncio.timeout() 上下文管理器,并在捕获 CancelledError 时完成资源清理(关闭连接、释放信号量、回滚事务)。注意:吞掉 CancelledError 会让取消语义失效,是另一个常见反模式。
性能优化落地清单
- 用
uvloop替换默认事件循环,IO 密集场景通常有 20%~40% 吞吐提升。 - 连接池化:HTTP 客户端、数据库连接都必须复用,避免每次请求重建握手。
- 控制日志级别与格式化开销,异步服务里同步写日志同样是阻塞源。
- 用
py-spy/asynciodebug 抓取循环阻塞点,用locust做压测验证优化收益。 - 为关键路径埋点:任务排队时长、单任务耗时、并发水位三个指标能覆盖大部分异步问题。
总结:asyncio 的收益来自「等待期间切换任务」,任何阻塞都会清零这份收益。把「不留阻塞、不无限并发、不吞异常」三条守住,异步服务才能真正跑出应有的并发能力。
- 点赞
- 收藏
- 关注作者
评论(0)