深色模式
04 · async/await 异步编程:一个人同时照看很多锅
学完你能说出:
- 协程、事件循环、
await分别是什么。- 为什么异步适合「同时发很多网络请求」,比如批量调用大模型 API。
- 最常见的坑:在异步函数里调用了同步的阻塞函数,整个程序都卡住。
先看地图
Python 模块最后一篇。第 01 篇说 IO 密集任务可以用多线程;这一篇讲另一种更轻量的方案:异步(asyncio)。FastAPI、各家大模型 SDK 都大量使用异步。
本篇讲 4 个点:① 同步 vs 异步 → ② 协程和事件循环 → ③ 并发执行很多请求 → ④ 什么时候不该用。
一句话本质
异步 = 一个线程里,遇到「等待」就先去干别的,等好了再回来接着干。
生活类比:一个厨师煮 3 锅汤
- 同步:煮第一锅,站在锅边等 10 分钟,煮好再煮第二锅……3 锅要 30 分钟。
- 多线程:请 3 个厨师,每人看一锅。10 分钟,但人工贵。
- 异步:一个厨师把 3 锅都放上火,哪锅开了就去处理哪锅。约 10 分钟,只要一个人。
- 关键前提:煮汤的大部分时间在等。如果是切菜(一直在干活,CPU 密集),一个人还是只能一刀一刀切,异步帮不上忙。
一步步看:调用 3 次大模型 API(每次等 2 秒,数字是示意)
同步写法:6 秒
python
import time
def ask(q):
time.sleep(2) # sleep = 睡眠:模拟等待网络响应 2 秒
return f"回答:{q}"
for q in ["问1", "问2", "问3"]:
print(ask(q)) # 一个一个来:2 + 2 + 2 = 6 秒异步写法:约 2 秒
python
import asyncio # asyncio = async 异步 + io 输入输出
async def ask(q): # async def 定义一个「协程函数」
await asyncio.sleep(2) # await = 等待:在这里让出控制权,去干别的
return f"回答:{q}"
async def main():
results = await asyncio.gather( # gather = 收集:同时启动多个协程,等全部完成
ask("问1"), ask("问2"), ask("问3")
)
print(results)
asyncio.run(main()) # run = 运行:启动事件循环,执行 main时间线:
0 秒:启动「问1」→ 遇到 await,让出 → 启动「问2」→ 让出 → 启动「问3」→ 让出
(3 个请求都在等网络)
2 秒:3 个都返回了 → 依次继续执行 → 全部完成三个关键概念
| 概念 | 是什么 | 类比 |
|---|---|---|
| 协程(Coroutine) | 用 async def 定义的函数,能在 await 处暂停、之后再继续 | 一锅汤 |
| 事件循环(Event Loop) | 调度者:不停检查「哪个协程等好了」,让它继续跑 | 厨师 |
| await | 「这里要等,我先让出去」的标记 | 把锅放上火,转身去看别的锅 |
为什么这么设计
为什么有了多线程还要异步?
- 问题:同时处理几千个网络请求时,开几千个线程开销很大(每个线程都占内存,切换也有成本),还要处理线程安全问题。
- 方案:在一个线程里用协程,只在
await处切换,切换由程序自己控制。 - 好处:一个协程只占很少内存,轻松上万个;只在
await处切换,不会在一行代码中间被打断,共享数据更不容易出错。
一句话答法
异步在单线程内用协程处理并发,开销远小于线程,适合大量 IO 等待;而且只在 await 处切换,比多线程更少出现竞争问题。
为什么要显式写 await?
- 问题:事件循环需要知道「什么时候可以切换去干别的」。
- 方案:程序员在会等待的地方明确写
await,表示「这里可以让出」。 - 好处:切换点清清楚楚,读代码就知道哪里可能被打断。代价是:漏写 await、或者调用了不支持 await 的阻塞函数,整个程序都会卡住。
一句话答法
await 明确标出「这里在等,可以让出控制权」,事件循环据此切换协程;切换点可见,但也要求等待操作必须是可 await 的异步版本。
最大的坑:在异步函数里调用阻塞函数
python
async def ask(q):
time.sleep(2) # ✗ 同步阻塞:厨师站在这锅前面发呆 2 秒,别的锅全没人管
requests.get(url) # ✗ requests 是同步库,同样会卡住整个事件循环
async def ask(q):
await asyncio.sleep(2) # ✓ 异步版本
async with httpx.AsyncClient() as c: # ✓ httpx 有异步客户端
await c.get(url)规则:在 async def 里,所有等待都要用异步版本(await 得了的)。实在要调用同步库,用 await asyncio.to_thread(函数, 参数) 把它放到别的线程里执行。
怎么选
| 场景 | 选择 |
|---|---|
| 大量网络请求(批量调 API、爬虫、Web 服务) | 异步 |
| 少量 IO,或者用的库只有同步版本 | 多线程 |
| CPU 密集计算 | 多进程(见第 01 篇) |
英文术语拆解
| 术语 | 拆开看 | 一句话意思 |
|---|---|---|
| async | asynchronous 异步的 | 声明一个异步(协程)函数 |
| await | 等待 | 在这里等结果,同时让出控制权 |
| Coroutine | co 共同 + routine 例程(函数) | 协程:能暂停和继续的函数 |
| Event Loop | event 事件 + loop 循环 | 事件循环:调度协程的核心 |
| asyncio | async + io 输入输出 | Python 的异步标准库 |
| gather | 收集 | 并发运行多个协程,等它们全部完成 |
| Blocking | block 阻塞 + -ing | 阻塞:调用后一直卡着,直到完成才返回 |
| Concurrency / Parallelism | 并发 / 并行 | 并发是交替推进,并行是真正同时执行 |
面试怎么问
Q:协程和线程的区别?
线程由操作系统调度,可能在任意位置被切换,需要加锁保护共享数据,每个线程开销较大;协程在用户态由事件循环调度,只在 await 处主动让出,开销很小,适合大量 IO 并发。协程本身在单线程内运行,不能利用多核做计算。
Q:并发和并行的区别?
并发是多个任务交替推进,宏观上看起来同时在做;并行是多个任务在多个 CPU 核上真正同时执行。asyncio 实现的是并发,多进程可以实现并行。
Q:在 async 函数里调用 time.sleep 或 requests 会怎样?
会阻塞整个事件循环,所有协程都停住,异步失去意义。应改用异步版本(asyncio.sleep、httpx、aiohttp),或用 asyncio.to_thread 把同步调用放到线程里执行。
容易混的地方
- 调用协程函数不会执行它:
ask("问1")只是创建了一个协程对象,要await它、或交给gather/run,才会真正执行。 - async 不会让代码自动变快:只有存在大量等待、并且等待可以重叠时才有用。
- 并发 ≠ 并行:一个厨师照看三锅是并发;三个厨师各看一锅是并行。
和其他知识的连接
- ← 来自 GIL 与多线程:IO 密集任务的另一种、更轻量的解法。
- ← 来自 迭代器、生成器与 yield:都是「暂停 → 继续」的思想。
- ← 来自 IO 模型与零拷贝:asyncio 底层依赖操作系统的 IO 多路复用(如 epoll)。
- → 影响 工具调用:Agent 一次请求多个工具时,可以用 gather 并发执行。
自测 3 问
Q1:用「一个厨师煮 3 锅汤」类比,解释同步、多线程、异步的区别。
点开看答案
同步:一锅一锅煮,站着等每一锅(30 分钟)。多线程:请 3 个厨师各看一锅(快但人工贵)。异步:一个厨师把 3 锅都放上火,哪锅好了处理哪锅(快,而且只要一个人)。前提是大部分时间在等。
Q2:3 个各需等待 2 秒的请求,用 asyncio.gather 并发执行,大约需要多久?为什么?
点开看答案
大约 2 秒。每个协程遇到 await 就让出控制权,3 个请求的等待时间互相重叠,而不是 2 + 2 + 2。
Q3:在 async 函数里调用 time.sleep(2) 会发生什么?怎么改?
点开看答案
time.sleep 是同步阻塞调用,会让整个事件循环停 2 秒,所有协程都卡住。改成 await asyncio.sleep(2);如果必须调用同步函数,用 await asyncio.to_thread(...) 放到线程里执行。
✅ 验收清单
- 能用「厨师煮汤」类比,区分同步、多线程、异步
- 能说出协程、事件循环、await 各自是什么
- 能手写用 asyncio.gather 并发执行多个协程的代码,并说出耗时
- 能讲清「有了多线程为什么还要异步」(问题 → 方案 → 好处)
- 能说出在 async 函数里调用阻塞函数的后果和改法
下一步该懂什么
- 回到 AI 知识库:批量调用大模型、Agent 并发执行工具,都会用到这一篇。→ 工具调用
- 想更深入:了解 FastAPI 里
async def接口和普通def接口的区别。