Python

Python asyncio 异步编程:事件循环与协程实战

写爬虫或调用 API 时顺序执行 100 个请求要几十秒,其中绝大部分时间都在等网络。本文从生成器到协程讲清 asyncio 的事件循环模型,用 Task 与 gather 组织并发,并给出限流与避坑的实战写法。

假设要抓取 100 个接口,每个请求耗时 200ms,其中 199ms 都花在等待网络回包上。用同步写法串行跑完要 20 秒, 但 CPU 实际忙碌的时间可能不到 0.1 秒。asyncio 要解决的问题就是:别让程序在等待中白白空转。

异步不是让代码”更快”,而是让单线程在等待 IO 时去做别的事。它适合 IO 密集型任务,对 CPU 密集型几乎没有帮助。

1. 同步模型为什么慢

同步(阻塞)代码的执行流是一条直线:发起请求 → 等待 → 拿到结果 → 下一个。等待期间线程处于挂起状态, 既不消耗 CPU,也不能做别的事情。多线程可以缓解,但线程的创建与切换有成本,成百上千个阻塞线程会把内存和调度器压垮。

异步的思路完全不同:由一个事件循环统一管理所有任务。当某个任务遇到 IO 等待时, 它主动把控制权交还循环,循环立刻去执行其他就绪任务,等 IO 完成后再回来接着跑。

2. 协程与事件循环

用 async def 定义的函数叫协程函数,调用它并不会执行函数体,而是返回一个协程对象。 协程对象必须被事件循环调度才会真正运行,入口是 asyncio.run()。

import asyncio
import time

async def fetch(name, delay):
    # await 是挂起点:此处让出控制权,循环可去跑别的任务
    await asyncio.sleep(delay)
    return f"{name} 完成"

async def main():
    start = time.perf_counter()
    # 顺序 await:总耗时是两者之和
    a = await fetch("A", 1.0)
    b = await fetch("B", 1.0)
    print(a, b, f"{time.perf_counter() - start:.2f}s")

asyncio.run(main())   # 输出约 2.00s

注意这里的结论可能反直觉:光写 async/await 并不会产生并发。上面这段代码仍是串行的, 因为第二个 await 在第一个完成之前根本没机会开始。

3. Task:把协程交给循环调度

要真正并发,必须把协程包装成 Task,让事件循环同时持有它们。 asyncio.create_task() 会立即把协程注册进循环并开始调度,之后再用 await 取回结果。

async def main():
    start = time.perf_counter()
    # 立即注册,两个协程同时开跑
    t1 = asyncio.create_task(fetch("A", 1.0))
    t2 = asyncio.create_task(fetch("B", 1.0))

    # 再取结果,此时两者大概率都已完成
    print(await t1, await t2)
    print(f"{time.perf_counter() - start:.2f}s")   # 约 1.00s

Task 一旦创建就独立运行,即使你不再 await 它。这也意味着如果任务里抛了异常而没人取结果, 异常会被静默吞掉——所以建议始终持有 Task 引用,或统一交给 gather 管理。

4. 并发组织与限流

任务一多,手动创建 Task 就不现实了。asyncio.gather 接收一批协程并发执行,并按传入顺序返回结果列表; 想”谁先完成谁先处理”,可以用 asyncio.as_completed。

import aiohttp

# 用信号量把并发限制在 10,避免把对端打挂或被限流
sem = asyncio.Semaphore(10)

async def fetch_url(session, url):
    async with sem:
        async with session.get(url) as resp:
            return await resp.text()

async def main(urls):
    async with aiohttp.ClientSession() as session:
        tasks = [fetch_url(session, u) for u in urls]
        results = await asyncio.gather(*tasks, return_exceptions=True)
    return results

# asyncio.run(main(urls))

return_exceptions=True 是个实用开关:任一请求失败时不会中断整个 gather, 而是把异常对象放进结果列表,由调用方决定怎么处理。另外要注意:HTTP 会话应当复用, 在一个 ClientSession 里共享连接池,比每次请求都新建会话快得多。

5. 四个常见陷阱

串行 100 次 × 200ms = 20s;20 并发 ≈ 100 / 20 × 200ms = 1s (1)

6. 总结

asyncio 的核心只有三件事:事件循环负责调度,协程通过 await 声明”这里可能等待”, Task 让多个协程真正同时推进。理解了这三点,绝大多数异步代码都能读懂。

落地时记住:IO 密集型才用异步;接进异步体系就不要再混入阻塞调用; 并发一定要限流。做到这三点,异步代码的收益会立刻体现在耗时上。