asyncio 踩坑实录:这个问题坑了我3小时

4 阅读6分钟

事情发生在去年给内部运维平台加一个“批量检测域名存活”功能的时候。需求很简单:定时轮询 1000+ 个域名,检查 HTTP 状态码,超时 5 秒就算挂了。我心想这不就是 IO 密集型任务嘛,asyncio 一亮,几分钟就能跑完。于是一顿 async defawaitgather 操作,自信满满点下运行。结果呢?1000 个域名跑了整整四分多钟,几乎跟同步顺序请求没差别。我盯着屏幕,感觉被 Python 当众打脸。

接下来的三个小时,我经历了一次对 asyncio 认知的彻底刷新。如果你也曾在 async 函数里“一不小心”阻塞了事件循环,或者在 gather 里丢了异常却浑然不知,这篇踩坑记录应该能帮你省下不止三小时。


一、问题复现:并发了但又没完全并发

先看我当时写的“并发”代码(你猜问题出在哪):

import asyncio
import time
import requests

async def check_domain(url: str) -> dict:
    """检测单个域名的状态码和耗时"""
    start = time.monotonic()
    try:
        # 注意这里用的是 requests,同步库
        resp = requests.get(url, timeout=5, allow_redirects=True)
        status = resp.status_code
    except Exception as e:
        status = str(e)
    elapsed = time.monotonic() - start
    return {"url": url, "status": status, "elapsed": elapsed}

async def main():
    urls = [f"https://httpbin.org/delay/1?n={i}" for i in range(50)]  # 模拟慢速接口
    t_start = time.monotonic()
    # 希望全部并发
    results = await asyncio.gather(*[check_domain(url) for url in urls])
    t_end = time.monotonic()
    print(f"总耗时 {t_end - t_start:.2f} 秒,完成 {len(results)} 个检测")
    # 打印前 3 个结果
    for r in results[:3]:
        print(r)

if __name__ == "__main__":
    asyncio.run(main())

你一眼可能就看出毛病了:在 async 协程里调用了同步阻塞的 requests.get。但当时我满脑子都是“用了 async 定义的就是协程,用 gather 就会并发”,完全忽略了事件循环的底层规则。50 个 URL,每个 delay 1 秒,总耗时超过 50 秒——活生生的排队请求。

这个坑的根源在于 asyncio 是单线程事件循环模型async def 本身不会让你的代码自动并发,它只是告诉解释器“这个函数可能会 yield 控制权”。真正让出控制权的动作是 await ——但前提是 await 后面的对象必须是一个真正的异步实现(例如 aiohttp 的请求)。requests.get 底层的 socket 操作全是同步阻塞的,一个协程在等待它时,整个线程都被卡住,事件循环根本没机会切换到其他任务。你写了 gather,它依然是一个接一个执行。

解法很简单:用异步 HTTP 库,比如 aiohttp

import aiohttp
import asyncio
import time

async def check_domain_async(session: aiohttp.ClientSession, url: str) -> dict:
    """真正的异步检测"""
    start = time.monotonic()
    try:
        async with session.get(url, timeout=aiohttp.ClientTimeout(total=5)) as resp:
            status = resp.status
    except Exception as e:
        status = str(e)
    elapsed = time.monotonic() - start
    return {"url": url, "status": status, "elapsed": elapsed}

async def main_async():
    urls = [f"https://httpbin.org/delay/1?n={i}" for i in range(50)]
    t_start = time.monotonic()
    async with aiohttp.ClientSession() as session:
        tasks = [check_domain_async(session, url) for url in urls]
        results = await asyncio.gather(*tasks)
    t_end = time.monotonic()
    print(f"总耗时 {t_end - t_start:.2f} 秒,完成 {len(results)} 个检测")

换成 aiohttp 后,50 个请求大约 1.5 秒完成(只要目的服务器扛得住),速度直接起飞。这个常识性的坑花了我至少一小时,因为它太反直觉:“定义成 async 就以为异步了” 是初学者乃至三年经验的人都可能犯的错。


二、更深一层的坑:gather 吞掉你的异常

你以为这就结束了?更隐蔽的坑还在后面。在后续迭代中,我加了一条健康检查逻辑:如果某个域名连不上,就触发报警。但后来发现,某些域名明明已经挂了,代码却毫无动静。又排错一个小时,才发现是 asyncio.gather 的异常处理在捣鬼。

默认情况下,如果传给 gather 的某一个协程抛出异常,gather 不会立即取消其它任务,而是会在它 await 的地方抛出该异常。我当时的代码结构大致是:

try:
    results = await asyncio.gather(*tasks)
except Exception:
    logger.error("批量检测出错")

这导致:只要任何一个域名 aiohttp 连接失败(比如超时),gather 就会向上抛异常,执行 except 分支,但剩余还在运行的任务会被继续执行直到结束(然后被静默丢弃)。更糟的是,我根本没拿到那些成功的结果,因为异常一抛出,results 变量都没被赋值。

正确做法是使用 return_exceptions=True 参数,让 gather 把异常也当作“正常返回值”收集起来,然后逐个判断:

results = await asyncio.gather(*tasks, return_exceptions=True)

for i, result in enumerate(results):
    if isinstance(result, Exception):
        print(f"任务 {urls[i]} 异常: {result}")
    else:
        print(f"任务 {urls[i]} 成功: {result}")

这样既能拿到所有任务的状态,又不会丢异常信息。如果你想在第一个失败时就立即取消所有剩余任务,可以用 asyncio.wait 配合 FIRST_EXCEPTION 策略,但那就更复杂了,需要手动管理 Task 句柄。


三、真实场景的“必杀技”:把同步代码丢进线程池

踩了前两个坑之后,还有一个现实难题:不是所有库都有异步版本。比如你要用 PyMySQL 读数据库,或者调一个黑盒 SDK,它们全是同步阻塞的。怎么办?硬塞进 async 函数显然又会阻塞事件循环。此时就该 loop.run_in_executor 出场了:

import asyncio
import time
import requests  # 依然用同步 requests

def sync_check(url: str) -> dict:
    """这个函数是同步的,可以被放到线程池中执行"""
    start = time.monotonic()
    resp = requests.get(url, timeout=5)
    elapsed = time.monotonic() - start
    return {"url": url, "status": resp.status_code, "elapsed": elapsed}

async def main_executor():
    urls = [f"https://httpbin.org/delay/1?n={i}" for i in range(50)]
    loop = asyncio.get_running_loop()
    tasks = [
        loop.run_in_executor(None, sync_check, url)  # None 表示用默认线程池
        for url in urls
    ]
    results = await asyncio.gather(*tasks, return_exceptions=True)
    # results 中的每一项就是 sync_check 的返回值或异常
    print(f"完成 {len(results)} 个检测")

这里 run_in_executor 把同步函数丢到线程池中执行,主线程的事件循环不受阻塞。它返回的是一个 asyncio.Future,可以当协程一样 await。对于无法改造为异步的代码,这招就是救命稻草。注意默认线程池大小是 CPU 核心数+4,如果并发量大可以自己创建 concurrent.futures.ThreadPoolExecutor 传入。


踩坑 & 注意事项

  1. “async 定义 ≠ 异步执行”
    只有 await 了真正的异步对象(如 aiohttpaiomysqlasyncio.sleep)才会让出控制权。同步调用(time.sleeprequests.getsocket.recv)会彻底阻塞整个事件循环。

  2. gather 的异常行为要显式指定
    默认抛出异常会导致部分成功结果丢失。加上 return_exceptions=True,把所有结果拿回来,再统一处理。千万不要裸写 await gather(...) 而不接异常。

  3. 不要在 async 函数里做 CPU 密集计算
    即便没有 IO,一个耗时 3 秒的 for 循环同样会卡死事件循环。这种场景要么扔进 run_in_executor,要么用 asyncio.to_thread(Python 3.9+),或者干脆换多进程。

  4. 小心 create_task 的“后台任务”丢失
    asyncio.create_task 创建的任务如果未被 await,当父协程结束时可能被取消。记得保存 Task 引用并在合适处等待。

  5. 测试环境与生产环境的事件循环策略差异
    Windows 上默认用 ProactorEventLoop,读写管道时行为与 Linux 不同。如果依赖于 add_reader 等低层 API,务必在目标平台上测试。


总结

asyncio 不是魔法,理解它单线程事件循环的本质,才能真正写出并发高性能的代码。

#Python #异步编程 #asyncio #性能优化 #踩坑日记