想象你是一家火爆餐厅里唯一能记事的服务员。同步模式是你点完一桌菜就守在厨房门口干等,菜不出锅你哪儿也去不了,其他顾客全饿着肚子排长队。而 async/await 教你的,是把菜单递给厨房后喊一声“菜好了叫我”,然后转身去服务下一桌;等厨房叫号,你回头端菜送餐就行。这一个看似简单的转变,让你在同一段时间里服务了更多顾客,还不用多招人手——这就是异步编程的魔法。本文就用“餐厅点餐”这个比喻,带你从零理解 Python 的 async/await 语法:协程是什么、await 如何挂起与恢复、事件循环怎么调度,以及最常见的坑如何避开。
对 AI 从业者而言,批量调用大模型接口、抓取训练数据、搭建 AI 服务后端都重度依赖 IO 等待;掌握 async/await,就是拿到吞吐量的“免费午餐”,是工程能力的硬通货。
目录
可视化演示
本节配有交互动效(进入视口后自动加载,约 3KB)。
协程挂起恢复执行流程 await 处协程挂起,控制权交还事件循环调度其他就绪任务 事件循环检测到 IO 完成后恢复挂起的协程继续执行 点击下一步逐步观察协程挂起、切换、恢复的完整过程 await 实现协作式挂起恢复,单线程高效调度多协程
协程初体验:async 函数是“会分身的外卖员”
餐厅里有两种员工:普通员工必须站在灶台前等菜做熟,期间什么都干不了;而“会分身的外卖员”记性极好,能同时记住十几桌的订单进度——哪桌菜马上好、哪桌还在配菜,他脑子里有张活地图。在 Python 里,async def 定义的就是这种“会分身”的函数,它在 IO 等待的空档里切换视线,谁需要处理就去帮谁搭把手。
async def 声明的函数叫协程函数。和普通函数最大的不同是:调用它并不会立刻执行函数体,而是返回一个协程对象。这个对象像一个“预订凭证”,里面记录了函数从哪开始、状态如何,但它自己不会跑。协程对象必须被交给事件循环(相当于大堂经理)调度,才会真正开始执行。
这种“定义-交出凭证-调度执行”的流程,用公式表达就是:
回到餐厅的比喻:写好一份菜单(async def)、把菜单交到服务员手里(调用得到协程)、服务员真正跑起来去后厨下单(事件循环调度)。前两步都不动火,第三步才真正开炒。
- 定义 async 函数用
async def修饰的函数就是协程函数,它是异步世界的基本单元。 - 调用得到协程对象执行
order_food()时,Python 只是打包了一个协程对象,函数体一行代码都没跑。 - 事件循环运行把协程对象交给
asyncio.run()等调度入口,事件循环才会真正驱动函数体执行。
事件循环:大堂经理的调度板
餐厅的大堂经理手里永远拿着一块调度板,上面是所有桌号的用餐进度:哪桌刚点完菜、哪桌菜已上齐、哪桌在催。经理不需要自己端菜,他只负责“催人”和“排顺序”。asyncio 的事件循环就是这样一个大堂经理,它管理着所有协程的“进度条”,谁该继续跑、谁还在等 IO,全部由它调度。
在 Python 中,asyncio.run(main()) 是启动事件循环的标配入口——它负责创建循环、运行传入的协程、运行结束后关闭循环。事件循环内部维护两个核心队列:就绪队列(可以立刻执行的协程)和等待队列(正在等 IO 的协程)。当协程遇到 IO 等待时,它会主动“让位”,循环把它挪到等待队列,然后从就绪队列里挑下一个协程执行。
这个“跑起来-等-回来接着跑”的循环过程,可以写成调度模型:
大堂经理从不亲自下厨,他只管调度;事件循环也从不执行具体业务,它只负责让每个协程在正确的时机跑一小段。
- 从就绪队列取出事件循环每次选择一个就绪的协程开始执行,就像经理叫号让下一桌顾客点菜。
- IO 等待转入等待队列协程执行到
await一个耗时 IO 操作时,主动挂起并转入等待队列。 - IO 完成回到就绪磁盘、网络等 IO 完成后,回调把协程放回就绪队列,等待下一次调度。
await挂起机制:菜好了叫我
有了“叫号”机制,服务员终于敢同时服务多桌了。关键动作就是 await——它像服务员把订单递进厨房时说的一句“菜好了叫我”。说完这句话,服务员转身就去服务别的顾客,完全不占用双手。等厨房喊号,他才回头端菜。
在 Python 中,await 后面必须跟可等待对象(协程对象、Task、Future)。await 做的事有两件:第一,挂起当前协程,把它“暂停”;第二,把控制权交还给事件循环,让循环去调度其他协程。当被等待的 IO 操作完成,事件循环会把当前协程放回就绪队列,等它下次被调度时,从 await 那一行继续往下走。
这套“挂起-让位-恢复”的机制,用公式表达:
await 就像那句“菜好了叫我”——挂起不是不干活,而是把手腾出来去干别的;恢复也不是重新开始,而是从之前暂停的那一行接着做。
- 执行 await 表达式协程执行到
await some_task这一行,开始等待那个对象的结果。 - 挂起当前协程当前协程暂停执行,内部状态被保存,控制权交还给事件循环。
- IO 完成后恢复目标对象完成后,事件循环把当前协程重新放进就绪队列,从挂起点继续。
函数对比:普通服务员和“分身服务员”
普通餐厅员工的工作方式是“一条龙”:点菜、等菜、上菜必须一口气做完,中途不能干别的。他们的 def 函数也是如此——调用即执行,直接返回结果。而“分身服务员”(async def)的工作方式则是“分段式”:接单(创建协程)、等菜(await 挂起)、送菜(恢复执行),中间随时可以切去服务别人。
两者的核心差异按下表展开:普通函数调用后函数体立即执行并返回结果;async 函数调用后只返回协程对象,必须 await 或交给事件循环才能得到真正的结果。更关键的是,普通函数里不能直接使用 await——只有 async 函数内部才有“挂起”的权限,就像只有正式服务员才能把单子递进厨房。
这种“一个直接给结果,一个先给凭证”的对比:
普通服务员和分身服务员都能送菜,但前者必须全程站着等,后者可以先去招呼别的桌、最后回来送菜。函数也一样:def 简单直接,async def 灵活高效,代价是必须遵守“用 await 取结果”的规则。
- 普通函数调用即执行
f()被调用时函数体立刻跑完,直接拿到返回值v。 - async 函数返回协程
g()被调用时只得到一个协程对象,函数体还没跑。 - await 取出结果用
await g()等待协程执行完,才能拿到内部return的v。
并发组合拳:一次给多个服务员派单
服务员学会了await之后,还要学会“同时派单”:与其一桌一桌上菜,不如一次性把三张单子分别递给三个窗口,等它们各自叫号。asyncio.gather() 就是这个“批量派单”神器——它接受多个协程,让它们并发执行,并在所有协程都完成后汇总结果。
asyncio.create_task() 则更进一步:它把一个协程包装成 Task 对象,相当于给这个协程挂上独立的“进度牌”,可以后台运行、随时查询状态,还能让多个任务同时起步。用上这两个工具,原本串行要等 n 个请求挨个完成的场景,变成了 n 个请求同时等待。
IO 密集场景下,并发和串行的耗时差异用公式看得更清楚:
就像一次给三个窗口派单,服务员虽然还是一个人,但三份菜在厨房里是同时做的;他只需要在三个窗口之间跑腿,总时间从“三份菜依次做”变成“最慢那份菜的时间”。
- 串行总耗时n 个任务逐个执行,总时间等于各个任务耗时的累加。
- 并发总耗时n 个任务同时等待 IO,总时间约等于耗时最长的那一个。
- 适用条件这个收益成立的前提是任务以 IO 等待为主,而不是 CPU 计算为主。
排雷指南:新手后厨的注意事项
新手服务员最容易犯的错,是拿着菜单喊了一嗓子“我要点菜”,却忘了把单子递进厨房——顾客以为菜在做了,其实后厨压根没收到。对应到代码里,就是忘记 await:直接调用 async 函数,得到协程对象却不处理,Python 会报 RuntimeWarning: coroutine was never awaited,函数体根本没执行。
第二个常见的坑是在 async 函数里混入阻塞调用,比如 time.sleep(2)。这相当于服务员突然站在原地发呆两秒,其他所有顾客都被晾着——整个线程都被阻塞了,事件循环没法调度任何协程。正确做法是用 await asyncio.sleep(2),它只挂起当前协程,把控制权交还给事件循环。
第三个误区是认为用了 async/await 所有代码都会变快。实际上协程是单线程内的“并发”而非“并行”,CPU 密集计算场景(如大规模矩阵运算)用协程并不会加速。阻塞调用和挂起调用的区别:
排雷的诀窍其实和餐厅管理一脉相承:单子要递进厨房(记得 await)、等菜时别发呆(用挂起而非阻塞)、一个人别想同时炒十口锅(CPU 密集交给多进程)。守住这三条,你的异步代码就能丝滑跑起来。
- 阻塞调用
time.sleep(n)让整个线程停下来,事件循环也停摆,所有协程都被冻住。 - 挂起调用
await asyncio.sleep(n)只暂停当前协程,事件循环转而调度其他就绪协程。
AI 应用场景
批量调用大模型 API 加速推理
阶段:AI 应用开发 / 推理服务
解决的问题:一个智能问答系统往往要串行调用多个模型接口——先做意图识别、再做检索、最后做生成。每个请求耗时 1~3 秒,串行总耗时为三者之和。用 asyncio.gather 并发发起这些请求,在等待网络响应的空档让事件循环调度其他请求,总耗时从“求和”降为“取最大”,接口延迟显著下降。
具体案例:某 RAG 系统需要对用户问题并行调用“向量检索服务”和“重排序模型”,用 create_task 同时启动两个协程,再 await 合并结果,单次请求端到端延迟从 4.2 秒降到 2.5 秒,吞吐量翻倍。
异步爬虫采集训练数据
阶段:数据准备与清洗
解决的问题:训练数据抓取涉及大量 HTTP 请求,而网络请求 90% 的时间都花在等待响应上。同步爬虫一次只能请求一个页面,像服务员干等一桌菜;异步爬虫用协程并发发起大量请求,等待过程中自动切换去处理已返回的响应。
具体案例:用 async 爬虫抓取 1000 个商品评论页,同步实现耗时约 20 分钟,异步实现约 80 秒(受目标网站限流约束),效率提升约 15 倍。
AI 服务后端的高并发支撑
阶段:模型服务部署与运维
解决的问题:FastAPI 等框架的 async 路由函数中,await 数据库查询或下游模型调用时,单 worker 能同时维持大量连接,而不会像同步框架那样为每个请求占用一个阻塞线程。
具体案例:客服机器人后端用 async handler 处理 WebSocket 长连接,空闲连接挂起、收到消息恢复,一个进程稳定扛住数千并发连接,配合 asyncio.run 管理生命周期。
Python 演示
import asyncioimport time async def fetch(url, delay): print(f"[开始] 请求 {url}") await asyncio.sleep(delay) # 模拟网络IO等待 print(f"[完成] 收到 {url} 的响应") return f"<{url} 的内容>" async def main(): urls = [ ("https://api.a.com/model", 1.0), ("https://api.b.com/embed", 1.5), ("https://api.c.com/rank", 2.0), ] t0 = time.time() results = await asyncio.gather( *(fetch(url, delay) for url, delay in urls) ) print(f"并发总耗时: {time.time() - t0:.2f}s") print(results) if __name__ == "__main__": asyncio.run(main())
asyncio.gather 并发执行三个模拟网络请求,演示 async/await 让总耗时约等于最慢请求,而不是三个请求之和。async def fetch(url, delay):定义协程函数,内部用await模拟网络等待,等待期间让出控制权。await asyncio.sleep(delay)挂起当前协程,让事件循环去调度其他任务,不阻塞线程。results = await asyncio.gather(...)并发聚合三个协程,全部完成后一次性返回结果列表。*(fetch(url, delay) for url, delay in urls)星号解包生成器,把三个协程依次展开传给 gather。asyncio.run(main())创建并启动事件循环,运行 main 协程,结束后自动清理资源。
常见误区
误区“我明明调用了 async 函数,程序也跑完了,为什么结果全是空的?是不是代码里有 bug?”
正解调用 async 函数只生成协程对象,函数体根本不会执行。必须用 await 或把它交给事件循环调度,代码才会真正跑起来。就像跟服务员说“我要点菜”和“把单子递给厨房”是两回事——不递单子,后厨永远不会做菜,你还奇怪为什么没菜上桌。
为何常见:普通函数“调用即执行”的习惯太根深蒂固,初学者下意识把 async 函数当成普通函数对待。
误区“用了 async/await,我的代码就能充分利用多核 CPU,和 `ThreadPoolExecutor` 一样并行跑,速度翻倍!”
正解async/await 是单线程内的并发,它的威力在于 IO 等待期不空转,而不是同时执行多个计算。CPU 密集场景(如矩阵乘法)用协程不会变快,反而因调度开销更慢。服务员虽然能同时招呼好几桌,但同一时刻只有一双手在干活。
为何常见:“同时服务多桌”的直觉让人误以为“同时做多件事”,忽略了底层只有一个线程在轮转。
误区“反正我在异步函数里,用 `time.sleep(1)` 和 `await asyncio.sleep(1)` 差不多吧?不都是等一秒?”
正解time.sleep(1) 阻塞整个线程,事件循环停摆,所有协程都被冻住;await asyncio.sleep(1) 只挂起当前协程,事件循环继续调度其他任务。就像服务员接了一个电话十分钟不松手,全餐厅都干等着;而“菜好了叫我”只是暂时放下这桌,别人照样被服务。
为何常见:两者代码形态相似,都“等了一会儿”,初学者容易忽略它们对线程的不同影响。
误区“我写了个普通函数 helper(),在里面调用 async 函数再用 await 取结果,应该没问题吧?”
正解await 只能出现在 async 函数内部。用普通函数包裹 await 会直接触发语法错误 SyntaxError: 'await' outside async function。必须把 helper 也改成 async def,或者用其他方式(如 asyncio.run)在同步代码里驱动协程。普通员工没有“叫号取菜”的权限,只有“分身服务员”(async 函数)才能用这个机制。
为何常见:async 函数看起来只是多了个关键字,忽略了调用链上下游都必须是异步函数的约束。
误区“我把 main() 的返回值存了下来,想再跑一次是不是直接 `asyncio.run(coro)` 就行?反正函数模板还是那份。”
正解协程对象是一次性的。一个协程被 await 或 run 过之后就不能再用了,再次运行会报 RuntimeError: cannot reuse already awaited coroutine。每次运行前都要重新调用 async 函数创建新的协程对象。就像一张排队小票只能取一次餐,取完就作废,想再吃就得重新排队领新票。
为何常见:把协程对象当成可重复调用的“函数模板”,忽略了它是一次性的状态容器。
面试题
Q1. 调用一个 async def 声明的函数,返回的是什么?
- A.直接返回函数结果
- B.返回一个协程对象← 正确答案
- C.返回一个线程对象
- D.返回一个进程对象
答案:B。调用 async 函数只创建协程对象,函数体不会执行,需要 await 或事件循环调度。就像写好菜单不等于上菜,得把单子递给厨房才行。普通函数才直接返回结果。
Q2. 在普通函数中直接使用 await 关键字,会发生什么?
- A.自动创建事件循环
- B.程序报语法错误← 正确答案
- C.返回空值 None
- D.无限等待然后卡死
答案:B。await 只能出现在 async 函数内部,普通函数里直接写会触发 SyntaxError。这就像普通员工不能把单子递进后厨——没这个权限,语法层面直接拦住。
Q3. asyncio.run(main()) 的主要作用是什么?
- A.创建一个新线程去执行
- B.新建事件循环并运行协程← 正确答案
- C.编译协程函数成为字节码
- D.把协程转成普通函数
答案:B。asyncio.run 是异步程序的入口,它创建事件循环、运行传入的协程、结束后自动清理。相当于大堂经理开工——排好调度板,让所有任务转起来。
Q4. await asyncio.sleep(1) 与 time.sleep(1) 的核心区别是什么?
- A.前者挂起协程,后者阻塞线程← 正确答案
- B.前者阻塞线程,后者挂起协程
- C.两种写法的效果完全相同
- D.前者耗时一定比后者更短
答案:A。time.sleep 像是服务员原地发呆,整个餐厅都停摆;await asyncio.sleep 像是“菜好了叫我”,只挂起当前协程,事件循环还能调度别人。前者阻塞线程,后者只是让位。
Q5. 忘记 await 直接调用 async 函数,最常见的后果是什么?
- A.程序直接崩溃并退出
- B.协程未被执行且告警← 正确答案
- C.自动转成同步执行
- D.被线程池自动接管
答案:B。忘记 await 会得到一个从未被等待的协程对象,函数体不执行,同时抛出 RuntimeWarning。就像空喊了“我要点菜”却没把单子递给厨房,后厨根本没收到。
Q6. 下列哪种任务类型最适合用 async/await 提升性能?
- A.CPU 密集计算任务
- B.IO 密集等待任务← 正确答案
- C.大量浮点矩阵运算
- D.纯内存排序任务
答案:B。async/await 省的是“等待时间”,IO 密集任务(网络、磁盘)在等待时让位给其他协程,收益巨大。CPU 密集任务本来就在满负荷计算,协程切换反而增加开销。
Q7. 下列哪些属于 await 可以等待的可等待对象?
- A.协程对象← 正确答案
- B.Task 对象← 正确答案
- C.Future 对象← 正确答案
- D.普通列表对象
答案:ABC。可等待对象包括协程、Task、Future 三类。普通列表没有实现异步等待协议,不能直接 await。就像服务员只能等“后厨的单子”(可等待对象),不能等一张购物清单。
Q8. 在 async 函数里调用阻塞式接口(如 requests.get),会怎样?
- A.阻塞整个线程← 正确答案
- B.只阻塞当前协程
- C.不影响其他协程调度
- D.导致事件循环停摆← 正确答案
答案:AD。同步阻塞调用会卡住整个线程,事件循环无法调度任何协程,所有任务都被冻住。正确的做法是用异步 HTTP 库(如 aiohttp)配合 await,才能只挂起当前协程。
Q9. 关于 asyncio.gather 与 create_task,下列说法正确的是?
- A.gather 可并发运行多个协程← 正确答案
- B.create_task 会创建后台任务← 正确答案
- C.gather 会等待所有协程完成← 正确答案
- D.create_task 必须搭配 gather
答案:ABC。gather 把多个协程聚合起来并发执行并等待全部完成;create_task 把协程包装成 Task 在后台运行。两者可以独立使用,D 说“必须搭配”是错的——create_task 单独用也能跑。
Q10. 关于协程并发与线程并行,本质区别是什么?
- A.协程在单线程内切换执行← 正确答案
- B.协程可并行跑满多核 CPU
- C.协程之间需要加锁保护
- D.协程一定会比线程更快
答案:A。协程是单线程内的事件循环调度,切换是协作式的;线程并行依赖多核和操作系统抢占。协程并不保证多核并行,也不保证一定更快,它擅长的是 IO 密集场景下的高吞吐。