asyncio 踩坑实录:这个问题坑了我3小时,差点把生产环境搞崩

4 阅读1分钟

上周五下午五点,我正准备合上电脑开溜,告警群里突然炸了——线上数据采集服务超时率飙到40%,下游报表全白。我打开日志一看,上千个 URL 的爬虫任务还在用老旧的同步 requests 逐个爬,单个请求平均 1.2 秒,一轮下来要将近 20 分钟,而业务要求 5 分钟内必须跑完。当时脑子里只有一个念头:上 asyncio 重构成并发,下班前搞定。结果这一搞,踩了三个大坑,差点把服务搞崩。现在把血泪经验写出来,希望能帮你省下这三小时。


为什么 asyncio 是 IO 密集型任务的正解

asyncio 的核心是事件循环(Event Loop)加上协程(Coroutine)。你可以把事件循环想象成一个不断轮询的调度员,而每个协程就是一个可以主动暂停、交出控制权的任务。当某个协程在等待网络响应(IO)时,事件循环会立刻切换到其他就绪协程,CPU 几乎不会空转。这和传统多线程模型最大的区别是:asyncio 是单线程内的协作式调度,避免了线程切换开销和 GIL 锁竞争,尤其适合网络请求密集的场景。

我们常用的模式是:用 async def 定义协程函数,在里面 await 异步 IO 操作;然后用 asyncio.gather() 把多个协程同时丢给事件循环执行。总耗时取决于最慢的那个任务,而不是所有任务的时间累加。

但“懂原理”和“写对代码”之间,隔着好几条人命。


代码实战:从“同步坑”到“异步真香”

坑1:在协程里用了同步阻塞调用

刚开始我写了个最简单的并发爬虫,大意如下:

import asyncio
import requests  # 同步库,不能用!

async def fetch(url):
    # 错误示范:直接把同步的 requests 放在协程里
    resp = requests.get(url, timeout=5)   # 这次调用会阻塞整个线程!
    return resp.status_code

async def main():
    urls = ["https://httpbin.org/delay/1"] * 10
    tasks = [fetch(url) for url in urls]
    results = await asyncio.gather(*tasks)
    print(results)

asyncio.run(main())

运行时你会发现,所有请求依然是串行的,效果跟同步代码一模一样。原因很简单:requests.get() 是同步阻塞调用,它在等待网络 IO 的期间并不会交出控制权给事件循环,导致同一时间只有一个协程在跑。事件循环形同虚设。

正确做法:换用异步 HTTP 客户端,比如 aiohttphttpx.AsyncClient

import asyncio
import aiohttp

async def fetch(session, url):
    # 使用 aiohttp 的异步请求,await 时将控制权交还事件循环
    async with session.get(url, timeout=aiohttp.ClientTimeout(total=5)) as resp:
        return await resp.text()

async def main():
    urls = ["https://httpbin.org/delay/1"] * 10
    async with aiohttp.ClientSession() as session:
        tasks = [fetch(session, url) for url in urls]
        results = await asyncio.gather(*tasks)
    print(f"完成 {len(results)} 个请求")

asyncio.run(main())

这段代码才真正用到了事件循环的并发能力。10 个延迟 1 秒的请求,总耗时大约 1 秒出头,而不是 10 秒。我的爬虫任务从 20 分钟直接压到 2 分钟以内。

坑2:gather 遇到异常直接炸全家

当任务量增加到几百个 URL 时,偶尔会有几个请求超时或 DNS 解析失败。我发现只要其中一个协程抛异常,gather() 会把异常立刻向上传播,导致所有其他正在执行的协程也被取消,整个批次前功尽弃。这正是线上第一次部署时碰到的情景:一个小域名解析不到,全部任务熔断,下游又白屏了。

修复办法是使用 gather(..., return_exceptions=True),让它把异常作为结果对象返回,而不是中断流程。

async def fetch_with_sem(sem, session, url):
    async with sem:   # 限制并发数,防止瞬间占满文件描述符
        try:
            async with session.get(url, timeout=aiohttp.ClientTimeout(total=10)) as resp:
                return url, await resp.text()
        except Exception as e:
            return url, f"ERROR: {e}"

async def main():
    urls = [...]  # 几百个 URL
    sem = asyncio.Semaphore(50)  # 限制并发,避免触发系统或服务端限制
    async with aiohttp.ClientSession() as session:
        tasks = [fetch_with_sem(sem, session, url) for url in urls]
        results = await asyncio.gather(*tasks, return_exceptions=True)  # 关键!
    for url, content in results:
        if isinstance(content, Exception):
            print(f"{url} 失败: {content}")
        else:
            process(content)

加上 Semaphore 和有异常吞掉后的重试队列,服务才真正稳下来。


踩坑 & 注意事项:这些才是真正的杀手

1. 别在协程里调用 time.sleep()
time.sleep() 会让整个线程睡过去,事件循环完全停摆。一定要用 await asyncio.sleep() 来模拟异步等待。

2. 注意事件循环的生命周期
如果是在已有的同步 Web 框架(比如 Flask)里引入 asyncio,不要直接在视图里调用 asyncio.run(),因为每次请求会创建并销毁事件循环,开销巨大且状态不可控。正确做法是维护一个全局的 ClientSession,并用 asyncio.ensure_future() 或消息队列解耦。对于 FastAPI 这类原生异步框架,直接用 async def 视图即可。

3. aiohttp 的 ClientSession 必须复用
每次请求时临时创建 ClientSession 会因为 TCP 连接无法复用而导致性能骤降,还可能触发连接数耗尽。要在整个应用生命周期内(或极长的时间窗口内)重用同一个 session,并在程序退出时主动关闭。

4. 并发数不是越高越好
我曾经图快直接把 Semaphore 设成 200,结果目标域名因为承受不住从而反压限流,服务端大量抛出 ConnectionResetError。一般从 30~50 开始压测,逐步上调,找到吞吐和错误率的平衡点。

这些坑踩过来,我深切体会到:asyncio 不是银弹,它要求整个调用链必须全是异步的,一个同步阻塞调用就能毁掉整个并发模型。


总结

asyncio 的并发魔法,全藏在“协作式交出控制权”这一哲学里;阻塞是它最大的敌人,异常是它最容易背叛你的队友。

#Python #异步编程 #asyncio #爬虫 #性能优化