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

4 阅读1分钟

上周要给公司做一个舆情监控工具,需要从 200 多个站点并发抓取页面,原来的同步脚本跑一趟要 40 多分钟,老板说“这太慢了,能不能快一点”。我心想,这不就是典型的 IO 密集型任务吗,上 asyncio 就完事了。结果代码写出来一跑,耗时不但没缩短,反而比同步版本还慢了十几秒。盯着屏幕 debug 了整整 3 小时,最后发现是自己把同步的思维硬套进了异步框架里。这个坑,今天必须写出来。

为什么会更慢?先看那段让我崩溃的代码

当时我的第一版“异步”代码大概是这样的:

import asyncio
import requests
import time

async def fetch(url: str):
    # 想当然地在协程里用 requests.get
    resp = requests.get(url, timeout=10)
    return resp.text[:100]

async def main():
    urls = ["https://httpbin.org/delay/1"] * 20
    start = time.time()
    # 用 asyncio.gather 并发执行
    results = await asyncio.gather(*[fetch(url) for url in urls])
    print(f"耗时: {time.time() - start:.2f}s, 结果数: {len(results)}")

asyncio.run(main())

这段代码表面上开了 20 个协程,但实际跑出来的耗时和串行几乎一样。原因是 requests 是同步阻塞的,它在等待网络 IO 时会卡住整个线程,而 asyncio 的事件循环偏偏跑在同一个线程里。当一个协程调用 requests.get(),事件循环就被它硬生生堵死了,其他 19 个协程只能原地罚站,等前面这一个完成。所谓的“并发”变成了一个接一个的排队,只不过队列叫协程而已。

正确的打开方式:把阻塞还给阻塞,把异步还给异步

asyncio 的核心是事件循环 + 协作式调度。协程遇到 await 时主动交出控制权,事件循环趁机把等待 IO 的任务挂起,切换去执行其他已就绪的协程。但这一切的前提是:你用到的 IO 操作必须原生支持异步,也就是返回一个 awaitable 对象。只要有一个同步阻塞调用混进来,整个事件循环就会被污染。

下面才是正确的写法,配合 aiohttp 搞定异步 HTTP:

import asyncio
import aiohttp
import time

async def fetch(session: aiohttp.ClientSession, url: str):
    try:
        async with session.get(url, timeout=10) as resp:
            return await resp.text()
    except Exception as e:
        return f"ERROR: {e}"

async def main():
    urls = ["https://httpbin.org/delay/1"] * 20
    start = time.time()
    # 创建共享的 session,复用连接池,大幅降低开销
    async with aiohttp.ClientSession() as session:
        tasks = [fetch(session, url) for url in urls]
        results = await asyncio.gather(*tasks)
    print(f"耗时: {time.time() - start:.2f}s, 结果数: {len(results)}")

asyncio.run(main())

改动点不多,但每一点都踩在关键上:

  1. aiohttp 替代 requestsaiohttpsession.get() 返回的是协程,配合 await 在等待数据时不阻塞事件循环。
  2. 共享 ClientSession。在实际项目里,千万不要每次请求都新建 session,那样 TCP 连接无法复用,延迟和资源消耗都会飙升。async with 能自动管理生命周期。
  3. 把任务统一交给 asyncio.gather,总耗时近似等于最慢的那个请求,而不是所有请求之和。

测试一下:20 个目标,每个 delay/1 延迟 1 秒,正确版本只需约 1 秒出头,而错误的阻塞版本要 20 多秒。就这两步,性能直接翻了几十倍。

再加一个信号量,防止“好心办坏事”

如果换成 200 个乃至 2000 个 URL,无限制并发会出现两个问题:目标服务器可能受不了,本机文件描述符也可能耗尽。最佳实践是引入 asyncio.Semaphore 做并发控制,既保持高速又保证稳定:

import asyncio
import aiohttp

async def fetch(session, url, sem, max_retries=2):
    async with sem:  # 控制同时只有 N 个协程进入
        for attempt in range(max_retries + 1):
            try:
                async with session.get(url, timeout=10) as resp:
                    resp.raise_for_status()
                    return await resp.text()
            except Exception as e:
                if attempt == max_retries:
                    return f"FAILED({url}): {e}"
                await asyncio.sleep(2 ** attempt)  # 指数退避
    return None

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

这样一来,不管列表多大,并发量始终受限,同时重试机制也避免了因偶发网络抖动丢失数据。这才是生产级的写法。

踩坑 & 注意事项

  1. 同步库污染事件循环:除了 requests,像 time.sleep() 也会阻塞整个线程。异步环境里应该用 await asyncio.sleep()。如果实在要调用老同步库,可以用 loop.run_in_executor() 丢到线程池去执行。
  2. 忘记 await:写了协程却不 await,它永远不会执行,IDE 只会淡淡地提醒一句“coroutine is not awaited”。一个常见错误是 asyncio.gather(fetch(url) for url in urls),忘记加 * 解包,结果传进 gather 的是一个生成器对象,直接报错。
  3. 无限制并发打穿连接池:不限制并发数,轻则触发对方限流封 IP,重则耗尽本机 sockets。Semaphore 是你的安全带。
  4. 异常悄悄吃掉任务gather 默认情况下如果一个任务抛异常,会直接抛出 asyncio.CancelledError 之类的错误,但其余任务可能被取消。加上 return_exceptions=True 可以把异常当作返回值处理,避免互相影响。
  5. 调试困难:异步栈帧不像同步代码那样直观。可以用 PYTHONASYNCIODEBUG=1 或者 asyncio.run(coro, debug=True) 开启慢回调探测,帮你找到“不务正业”的阻塞调用。

总结

异步并发不是把同步代码套上 async/await 就完事了,真正起决定作用的,是底层 IO 实现是否与事件循环协作。

#Python #AsyncIO #异步编程 #爬虫 #掘金技术