假设要抓取 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. 四个常见陷阱
- 在协程里调用阻塞函数:
time.sleep()、requests.get()会卡死整个事件循环,因为它们不让出控制权。改用asyncio.sleep()和异步 HTTP 库,或把阻塞调用丢进asyncio.to_thread() - 忘记 await:调用协程函数只是创建对象,不写
await就等于什么都没执行,且不会有任何报错 - 并发不设上限:几千个任务同时发出,轻则被限流,重则耗尽文件描述符
- 混用多线程:asyncio 是单线程模型,跨线程操作循环必须用
loop.call_soon_threadsafe()
6. 总结
asyncio 的核心只有三件事:事件循环负责调度,协程通过 await 声明”这里可能等待”, Task 让多个协程真正同时推进。理解了这三点,绝大多数异步代码都能读懂。
落地时记住:IO 密集型才用异步;接进异步体系就不要再混入阻塞调用; 并发一定要限流。做到这三点,异步代码的收益会立刻体现在耗时上。
如果这篇文章对你有帮助,请我喝杯茶吧