Chapter 31: Async Runtime
async def 把一个普通调用现象拆成两层:函数调用先创建 coroutine object,event loop 后续再驱动这个对象执行。读完本章后,读者应能追踪一段 async 代码从 coroutine 创建、await 挂起、Task 调度、Future 完成到取消传播的状态变化,并能判断问题出在对象创建、调度入口、等待关系还是取消处理。
本章以一个贯穿示例为材料。它看起来像同步代码:先打开会话,再请求两个资源,最后汇总结果。runtime 视角下,它会被拆成 coroutine object、suspended frame、Task、Future、event loop callback 和异常路径。Python 语言层面的规则可参考 Python Data Model: Coroutines,asyncio 高层 API 可参考 Python 3.14 Coroutines and Tasks。
import asyncio
async def fetch_one(name: str, delay: float) -> str:
await asyncio.sleep(delay)
return f"{name}:ok"
async def fetch_many() -> list[str]:
first = asyncio.create_task(fetch_one("first", 0.1))
second = asyncio.create_task(fetch_one("second", 0.2))
return [await first, await second]
result = asyncio.run(fetch_many())
print(result)
这段代码的主路径是:fetch_many() 创建 coroutine object,asyncio.run() 创建并运行 event loop,create_task() 把子 coroutine 包装成 Task 并注册到 loop,await first 挂起当前 coroutine,asyncio.sleep() 内部通过定时器和 Future 表达“稍后完成”,Future 完成后唤醒等待它的 Task,Task 再把执行权交回 suspended coroutine。这个链条的核心结论是:async runtime 的调度单位是 Task,挂起点由 await 暴露,完成信号通常落在 Future 上,event loop 负责在这些状态之间推进。
31.1 Coroutine object and awaitable protocol
async def 定义的是 coroutine function。调用 coroutine function 时,Python 创建 coroutine object;函数体内的代码在这个时刻尚未进入执行。这个对象保存 code object、运行 frame 的创建入口、当前等待对象和运行状态。fetch_many() 这一行表达的是“创建一个可被驱动的执行对象”,真正的执行发生在 asyncio.run(fetch_many()) 接管之后。
这个差异可以用很短的观察代码定位。inspect.iscoroutine() 用来判断对象是否为 coroutine object,inspect.isawaitable() 用来判断对象能否进入 await 表达式。CPython 暴露的 cr_frame、cr_await、cr_running、cr_suspended 等属性也能帮助观察 coroutine 的状态;这些属性属于实现可观察面,版本边界可参考 inspect 对 coroutine 属性的说明。
import inspect
async def read_user(user_id: int) -> dict[str, int]:
return {"id": user_id}
coro = read_user(7)
print(inspect.iscoroutine(coro))
print(inspect.isawaitable(coro))
print(coro.cr_frame is not None)
coro.close()
这段代码的对象关系是:read_user 是 function object,read_user(7) 生成 coroutine object,coro 这个名字绑定到该对象。coro.cr_frame 在 coroutine 尚可执行时指向保存局部状态的 frame;coro.close() 关闭尚未运行的 coroutine,释放它保存的执行入口。未被运行或关闭的 coroutine 在销毁时可能触发 RuntimeWarning,因为 runtime 观察到一个创建后没有被消费的异步执行对象。
awaitable protocol 是 await 能接受对象的协议边界。一个对象通常通过 __await__() 返回 iterator,让 await 能从中取得挂起信号和最终结果。native coroutine object 天然是 awaitable,asyncio.Task 和 asyncio.Future 也实现了 awaitable 行为。PEP 492 把这组语义固定为 async / await 语法,可参考 PEP 492 的 await expression 说明。
这组对象的边界需要分清:coroutine object 表达“这段 async 函数体的执行状态”;Task 表达“被 event loop 调度的 coroutine”;Future 表达“某个异步操作未来会给出结果或异常”。同一个 await 语法可以等待三类对象,但它们承担的 runtime 角色不同。追踪 async 代码时,先问“这里创建了哪个对象”,再问“谁负责驱动它”,最后问“完成信号从哪里回来”。
coroutine object 还有一次性消费边界。一个 native coroutine 完成后,再次 await 同一个对象会触发 RuntimeError。这和 Future 的行为不同:Future 完成后可以多次 await,返回同一个结果或传播同一个异常。原因来自状态所有权:coroutine object 保存一段执行过程,执行结束后 frame 已收束;Future 保存完成结果,完成态可以被多个等待方读取。
31.2 await and coroutine suspension
await 是 coroutine 把执行权交回调度器的显式挂起点。它先求值右侧对象,再检查对象是否 awaitable,然后驱动该 awaitable。若 awaitable 还没有结果,当前 coroutine 的 frame 被保留在 suspended 状态;若 awaitable 已经完成,await 直接取得结果或传播异常并继续执行下一条语句。
把贯穿示例展开成状态关系,await first 的含义是:fetch_many 当前 Task 暂停执行,登记自己正在等待 first 这个 Task 的完成;event loop 可以运行其它 ready callbacks 或其它 Task;当 first 完成时,等待它的父 Task 被重新放入可运行队列。这个行为让 async 代码以顺序语法表达协作式调度。
import asyncio
async def child() -> str:
await asyncio.sleep(0.1)
return "done"
async def parent() -> str:
task = asyncio.create_task(child())
value = await task
return value.upper()
print(asyncio.run(parent()))
parent() 运行到 await task 时,parent 的 frame 中已经有局部变量 task,instruction pointer 停在 await 相关指令位置,value stack 上的临时对象已经按 bytecode 规则整理完毕。child 所属 Task 运行到 await asyncio.sleep(0.1) 时也进入暂停。event loop 看到两个 Task 的状态:父 Task 等子 Task,子 Task 等定时器 Future。定时器触发后,Future 变为完成态,子 Task 恢复并返回,父 Task 随后恢复并执行 value.upper()。
这个过程可以用状态图表达,图中只展示高层 runtime 角色,不展开每条 bytecode 指令。
图中的 Created 属于 coroutine object,Scheduled、Ready、Running 和 Cancelling 通常由 Task 管理。Suspended 说明 coroutine frame 被保留,等待对象可通过 cr_await 观察到一部分关系。Finished 与 Failed 都是终态,区别在于 Task 或 Future 保存的是返回值还是异常对象。
await 的工程含义是让出执行权。它只在 awaitable 仍未完成时产生调度切换;等待一个已经完成的 Future 或 Task 时,当前 coroutine 会继续向下执行。长时间运行的 CPU 计算函数若没有 await,event loop 很难插入其它 Task。asyncio.sleep(0) 常用于把一个长循环显式切成可让出执行权的小段,但这只是协作式调度点,计算本身仍然在同一个线程里消耗 CPU 时间。
异常在 await 处回到等待方。子 coroutine 抛出的异常会记录在它所属的 Task 上,父 coroutine await 该 Task 时重新得到这个异常。返回值也在同一位置回到等待方。于是调试 async 调用链时,应把 await 当成同步调用中的“返回或抛出”边界,同时额外考虑中间的暂停和调度窗口。
31.3 Task scheduling and Future relation
Task 是 event loop 调度 coroutine 的包装对象。asyncio.create_task(coro) 接收 coroutine object,创建 Task,把它绑定到当前正在运行的 event loop,并安排它尽快执行。贯穿示例中,first 和 second 两个 Task 被创建后,可以在 fetch_many 继续运行到第一个 await 之前进入 loop 的 ready 队列;它们的真实推进顺序由 event loop 每轮取出的 callback 和 Task 状态决定。
Task 的责任包括保存 coroutine、推进 coroutine、接收取消请求、保存最终结果或异常、唤醒等待它的对象。它是 async runtime 中最常见的并发句柄。拿到 Task 后,调用方可以 await task 等结果,可以 task.cancel() 请求取消,也可以通过 task.done()、task.result()、task.exception() 读取完成态信息。读取 result 或 exception 前要确认 Task 已完成,否则会得到状态错误。
Future 是更低层的完成信号容器。它表示“这个异步操作未来会完成”,并保存三类终态之一:结果、异常、取消。asyncio.sleep(0.1) 内部会创建与 loop 定时器关联的等待对象;定时器到点后,loop 让 Future 进入完成态并调度回调。高层业务代码通常创建 Task,底层协议、transport、定时器、线程池桥接和回调式接口更常直接操作 Future。官方 Future 文档把它定位为低层 awaitable,可参考 asyncio Future Object。
import asyncio
async def set_later(fut: asyncio.Future[str]) -> None:
await asyncio.sleep(0.1)
fut.set_result("ready")
async def main() -> str:
loop = asyncio.get_running_loop()
fut: asyncio.Future[str] = loop.create_future()
asyncio.create_task(set_later(fut))
return await fut
print(asyncio.run(main()))
这段代码中,main 创建 Future,set_later 作为 Task 被调度。main await Future 后暂停;set_later sleep 完成后调用 fut.set_result("ready");Future 完成后,等待它的 main 被唤醒并收到字符串结果。这个例子把 Task 和 Future 的责任分离得很清楚:Task 负责运行 coroutine,Future 负责保存某个尚未到达的结果。
Future 的用户可见 API 应控制在库边界内。业务函数直接返回 coroutine 或普通结果更容易维护;库函数在内部用 Future 桥接 callback、socket readiness、定时器和线程池结果。原因是 Future 暴露了写入完成态的能力,调用方若随意 set_result() 或 cancel(),会破坏库对异步操作生命周期的控制。
Task 和 Future 都能被 await,但完成来源不同。Task 的完成来自 coroutine 执行到 return、抛出异常或收到取消并传播。Future 的完成来自外部调用 set_result()、set_exception() 或 cancel()。这也是排查卡住问题的关键:Task 卡住时要看它的 coroutine 当前 await 谁;Future 卡住时要看谁承诺写入结果,以及那个回调是否被注册到 loop。
Task 生命周期还受强引用影响。asyncio.create_task() 返回的 Task 应保存引用,尤其是 fire-and-forget 场景。event loop 对 Task 的引用模型不应被当成业务所有权;官方文档建议把后台 Task 放入集合,并在完成回调中移除。这个规则服务于对象生命周期:只要业务还关心这个 Task 的完成和异常,就应保留明确的强引用。
Python 3.11 引入 asyncio.TaskGroup,用于把一组相关 Task 绑定到一个 async context manager。TaskGroup 退出时等待组内所有 Task;其中一个 Task 失败时,会取消同组剩余 Task,并把非取消异常组织成 exception group。它改变的是并发任务的生命周期管理方式,底层仍围绕 coroutine、Task、Future 和 event loop 推进。
31.4 Async state machine and cancellation
async runtime 的状态机围绕五类状态移动:创建、可调度、运行中、挂起、完成或失败。coroutine object 从创建态开始;Task 接管后进入可调度态;event loop 推进一步时进入运行中;遇到 pending awaitable 时进入挂起态;返回、异常或取消传播后进入终态。这个状态机是理解 async bug 的主线。
取消是状态机中的异常注入路径。调用 task.cancel() 会请求取消 Task;Task 在下一次可执行机会把 asyncio.CancelledError 注入到 coroutine 的挂起点。coroutine 可以用 try/finally 收束资源,例如关闭连接、释放锁、写出日志。清理完成后通常继续传播 CancelledError,这样外层 Task、TaskGroup 或 timeout 结构才能正确感知取消结果。官方任务取消说明见 asyncio Task cancellation。
import asyncio
async def worker() -> None:
try:
await asyncio.sleep(10)
finally:
print("cleanup")
async def main() -> None:
task = asyncio.create_task(worker())
await asyncio.sleep(0.1)
task.cancel()
try:
await task
except asyncio.CancelledError:
print("cancelled")
asyncio.run(main())
这段代码的状态变化是:worker 被 Task 调度后在 await asyncio.sleep(10) 处挂起;main 调用 task.cancel() 后,取消请求记录在 Task 上;main 再次 await task 时等待 Task 进入终态;event loop 恢复 worker,在原挂起点注入 CancelledError;finally 执行清理;异常继续传到等待 task 的 main,于是打印 cancelled。
取消传播的边界要按等待关系分析。父 Task await 子 Task 时,父 Task 的取消可能传给子 Task;asyncio.gather()、TaskGroup、timeout() 等组合 API 又会定义自己的传播规则。TaskGroup 的设计更偏结构化并发:组内失败会取消同组剩余任务,并在退出时统一交付异常。gather() 的默认行为更偏聚合等待:第一个异常会传给等待 gather() 的 Task,已提交的其它 awaitable 仍按文档规则继续或根据 gather 取消状态处理。
吞掉取消异常会改变上层结构的判断。某个 coroutine 捕获 CancelledError 后直接返回普通值,上层 Task 会看到完成态,timeout 或 TaskGroup 的内部取消语义也可能被扰乱。稳定做法是把 CancelledError 当成控制流信号:局部清理可以捕获它,清理后继续传播;只有在明确设计了取消转普通结果的 API 时,才把该选择写成接口契约。
状态机还解释了 async 代码中的“卡住”。一个 Task 长期 pending 时,先看它挂起在哪个 awaitable 上;若挂起对象是 Future,继续追踪谁负责写入结果;若挂起对象是 Task,继续追踪子 Task 当前等待谁;若 Task 正在运行 CPU 计算,检查是否缺少协作式让出点;若 Task 已取消但调用方没有 await 它,异常和清理路径可能迟迟没有被观察到。
31.5 Async runtime checklist
分析 async 代码时,先固定对象层级。看到 async def name(...),它只是定义 coroutine function;看到 name(...),它创建 coroutine object;看到 asyncio.create_task(name(...)),它把 coroutine 交给当前 event loop 调度;看到 await obj,它让当前 coroutine 等待 awaitable 的结果。这个顺序能把“函数已经调用”和“函数体已经运行”分开判断。
第二步检查调度入口。顶层 async 代码需要 asyncio.run()、已有 event loop 中的 await、create_task() 或框架提供的 runner 接管。缺少调度入口时,coroutine object 只存在于对象图中。已有 event loop 环境里再次调用 asyncio.run() 会触发运行时错误;库代码通常暴露 async API,由调用方或框架决定 runner。
第三步检查等待关系。每个 await 都应回答三个问题:等待的对象是什么类型,完成信号由谁写入,异常从哪里传播回来。等待 coroutine 时,当前 coroutine 直接驱动子 coroutine 的执行;等待 Task 时,当前 Task 等另一个被 loop 调度的 Task;等待 Future 时,当前 Task 等外部回调或底层事件写入结果。
第四步检查并发所有权。create_task() 会让 coroutine 与当前执行路径并发推进,调用方应保存 Task 引用并决定取消、等待和异常处理策略。多个相关任务应考虑 TaskGroup,把生命周期压进一个 async with 作用域;零散后台任务应集中登记,并在完成回调中移除,保证完成态可以被观察。
第五步检查取消路径。看到 timeout、TaskGroup、手动 cancel() 或上层请求结束时,沿等待关系追踪 CancelledError 注入点、finally 清理、异常传播和任务终态。捕获取消异常的代码需要写清是否继续传播。资源释放应放在 finally 或 async context manager 的 __aexit__ 中,让取消路径和普通返回路径共享同一套收束逻辑。
第六步检查阻塞边界。asyncio 的协作式调度依赖 Task 在 await 处交回控制权。同步文件 I/O、CPU 密集循环、长时间锁等待和阻塞式网络调用会占用 event loop 所在线程。排查延迟抖动时,先定位是否有长时间运行且缺少 await 的代码段,再决定拆分协作点、使用 executor、改成异步库,或把 CPU 工作移到进程池。
把这些检查点放回贯穿示例,可以得到一条稳定读法:fetch_many() 是 coroutine object 创建;asyncio.run() 是顶层 runner;两个 create_task() 建立并发子任务;await first 和 await second 建立等待边;asyncio.sleep() 用 Future 和定时器表达稍后完成;返回值沿 Future、Task、await 表达式回到父 coroutine;取消会沿 Task 等待关系注入并通过异常传播。
最小自检任务
阅读下面代码,判断输出顺序、对象关系和取消传播路径。要求说明:job() 调用产生了什么对象,create_task() 改变了什么,await task 等待的对象是什么,task.cancel() 后异常在哪里被注入。
import asyncio
async def job() -> str:
try:
await asyncio.sleep(1)
return "finished"
finally:
print("job cleanup")
async def main() -> None:
task = asyncio.create_task(job())
await asyncio.sleep(0)
task.cancel()
try:
print(await task)
except asyncio.CancelledError:
print("main saw cancellation")
asyncio.run(main())
答案要点
job() 在 create_task(job()) 的参数求值阶段创建 coroutine object。create_task() 把这个 coroutine object 包装成 Task,绑定到当前 event loop,并安排它执行。main 中的 await asyncio.sleep(0) 给 event loop 一次调度机会,job Task 因而可能运行到 await asyncio.sleep(1) 并挂起。
task.cancel() 请求取消该 Task。取消异常在 job 的挂起点注入,也就是 await asyncio.sleep(1) 所在位置。finally 先执行,因此会打印 job cleanup。随后 CancelledError 继续传播到等待该 Task 的 main,print(await task) 无法取得普通字符串,控制流进入 except asyncio.CancelledError,打印 main saw cancellation。
这个片段的核心判断是:coroutine object 自身只保存执行对象;Task 让它进入 event loop 调度;await task 等待 Task 的终态;取消以异常形式回到 coroutine 的挂起点,并沿 await 关系传给等待方。
本章知识点总结
- 调用分层:
async def调用先创建 coroutine object,执行需要 runner、await或 Task 接管。 - Awaitable 协议:能进入
await的对象需要提供 awaitable 行为,native coroutine、Task 和 Future 都属于常见 awaitable。 - Coroutine 状态:coroutine object 保存 code、frame、当前等待对象和运行状态,完成后同一个 native coroutine 对象只能消费一次。
- 挂起语义:
await在等待对象未完成时保存当前 frame,并把执行权交回 event loop。 - 恢复条件:被等待对象完成后,等待它的 Task 重新进入可运行队列,并在后续 loop step 中继续执行。
- Task 角色:Task 是 event loop 调度 coroutine 的句柄,负责推进执行、保存结果、保存异常和处理取消。
- Future 角色:Future 是低层完成信号容器,负责保存未来结果、异常或取消状态。
- 等待关系:排查 pending Task 时,先看当前 Task await 谁,再追踪该 awaitable 的完成信号来源。
- 取消路径:
cancel()会请求在 coroutine 挂起点注入CancelledError,清理代码通常放在finally或 async context manager 中。 - 传播边界:取消、返回值和普通异常都会沿 await 关系回到等待方,组合 API 会定义额外的传播规则。
- 结构化并发:TaskGroup 把一组相关 Task 的创建、等待、失败和取消放进同一个 async context manager 生命周期。
- 阻塞判断:asyncio 的并发依赖协作式让出点,长时间同步计算或阻塞调用会占用 event loop 所在线程。
- 读码顺序:读 async 代码时按 coroutine object、Task、Future、event loop、
await边和取消路径依次定位。