Python 异步编程踩过的五个坑

作者:晚风信箱 发布时间: 2025-08-09 阅读量:80 评论数:0

去年我把一个内容抓取服务从多线程模型改成了 asyncio,代码量少了大概三分之一,单机 QPS 从 80 涨到 600 出头。数字很好看,但中间踩的坑一个都不少,值得单独记一篇。下面五个,每一个都让我加过班。

一、在协程里调了同步阻塞代码

最经典也最丢人的一个。我用 aiohttp 抓页面,抓完顺手调了一个内部接口做去重校验——那段代码是从旧项目直接复制来的,用的是 requests。结果压测时并发上到 50 就再也涨不动了。我当时还煞有介事地怀疑对方接口限流,发消息问人家,被反问「你们才两个请求每秒,限什么流」。

回头看日志才明白,是我自己在 event loop 里同步等了 200 毫秒。单线程的事件循环,一个人卡住全场。

后来养成的习惯是:任何出现在 async def 里的第三方调用,都先问一句「它 await 了吗」。实在没有异步版本的 SDK,就扔进线程池:

loop = asyncio.get_running_loop()
result = await loop.run_in_executor(None, legacy_client.query, uid)

顺带一提,time.sleep 是同一类问题。我见过重试逻辑里写 time.sleep(1) 的,一次重试就是一秒全局停摆。

二、创建了协程却没有 await

写成 self.report(data) 而不是 await self.report(data),程序不报错,只在退出时甩一句 coroutine was never awaited 的警告。如果你的日志级别刚好把 warning 过滤了,那这个上报功能就是彻底静默地不工作。

我们有个埋点上报静悄悄挂了大概三周才被发现,就是这个原因。现在我在 CI 里开了 -W error::RuntimeWarning,宁可测试挂掉也不要静默失败。

三、gather 的异常吞掉了一半结果

asyncio.gather 默认行为是:任意一个子任务抛异常,立刻把异常抛给调用方,但其他任务并不会被取消,它们还在后台跑。于是你会看到一个诡异现象——主流程已经进了 except 分支,日志里却还在源源不断打出后续任务的输出。

如果你需要全部结果,用 return_exceptions=True,然后自己遍历判断哪个是 Exception 实例。我现在几乎不再裸用 gather 了,一律带上这个参数,宁可代码啰嗦一点。

四、并发量没有上限,把下游打挂了

改成异步之后我很兴奋,写了个列表推导式一次性 gather 了三万个任务。本机内存瞬间涨到 4G,对方服务直接 502。

异步的本质是取消了线程数这个天然的限流阀门,你必须自己把它加回来。我用的是最土的办法,一个 asyncio.Semaphore(20) 包在最外层:

sem = asyncio.Semaphore(20)

async def guarded(item):
    async with sem:
        return await fetch(item)

另外 aiohttp 的 TCPConnector 有个 limit 参数,默认 100,也建议根据下游承受能力调小。

五、取消(CancelledError)不是普通异常

这个坑最隐蔽。我在任务里写了 except Exception 做兜底重试,看起来很稳健。但超时取消时抛的 CancelledError 在 Python 3.8 之后继承的是 BaseException,不会被 except Exception 捕获——这本来是好事。问题出在我另一段代码里写了 except BaseException,于是任务被取消后又自己重试了一遍,超时形同虚设,一个卡死的请求反复重试了几十次。

结论是:清理资源用 finally,不要用宽泛的 except 去截取消信号;如果确实要在取消时做点事,捕获后一定要重新 raise。

写在最后的一点感受

异步编程最大的心智负担,不是语法,而是你必须时刻记得「现在这一行会不会让整个循环停下来」。同步代码里一次慢调用只影响一个请求,异步代码里它影响所有人。这种从局部到全局的思维切换,我大概花了两个月才真正建立起来。

另外一个不太政治正确的观察:如果你的服务瓶颈在 CPU 而不是 IO,或者并发量本来就只有几十,老老实实用线程池,可能比重写成异步划算得多。我后来又劝退过团队里两个想改异步的同事,理由就是这个。

评论