码农高天
await操作在当前任务上下文中同步推进生成器直至遇到未完成对象,依赖关系则由Future回调与事件循环调度驱动以保障上下文隔离与公平性。
AI-assisted summary of 【python】await机制详解。再来个硬核内容,把并行和依赖背后的原理全给你讲明白 — original source. Check the original for full context.
Source published:
Key ideas
- 直接 await 协程并不会创建 Task,而是像普通生成器一样在当前 Task 上下文中同步深入调用;事件循环调度的最小单位始终是 Task,无法直接调度裸协程。 (0:00)
- CPython 底层通过 GET_AWAITABLE 与 YIELD_FROM 字节码驱动 await,遇到未完成生成器时会回退程序计数器并退出当前帧,以便下次恢复执行。 (3:07)
- Task 继承自 Future,其 __await__ 方法在任务未完成时直接 yield self 将自身抛出,使外层捕获并建立依赖链条。 (6:51)
- 任务依赖通过 add_done_callback(self.__wakeup) 实现,当前任务在等待期间完全从事件循环中静默退出,直到被依赖方完成时通过 call_soon 重新排队唤醒。 (9:40)
Detailed notes
## Background 该视频由 YouTube 频道“码农高天”于 2023 年 9 月 29 日发布。视频简介指出,本期内容旨在详细解说 Python 中 await 背后的所有运行机制,将 asyncio 的一个隐秘角落彻底点亮,让协程不再神秘。
## TL;DR 视频首先勘误了“await 协程会自动将其转为 Task”的常见误区,并通过性能剖析工具 VizTracer 展示了直接 await 协程与显式创建 Task 在调用栈与控制权交接上的本质差异。随后,高天深入 CPython 源码,拆解了 GET_AWAITABLE 与 YIELD_FROM 这两条核心字节码如何驱动生成器的 send 方法,并利用指令计数器回退实现函数的挂起与恢复。最后,视频分析了 asyncio.Future 与 asyncio.Task 的源码实现,详细揭示了未就绪对象如何通过 yield self 挂起、借助 add_done_callback 建立依赖通知链,并依靠事件循环的 call_soon 机制实现兼顾上下文隔离与调度公平性的异步唤醒全流程。
## Atmosphere & style 整期视频呈现出硬核极客、务实严谨且轻松幽默的技术布道风格。高天开场以自嘲日更压力的坦诚态度迅速拉近与观众的距离,毫不避讳地主动纠错上一期的失误;而在进入源码分析阶段后,讲解层层推进、抽丝剥茧,配合直观的可视化轨迹图与底层 C 语言代码逐行剖析,给观众带来一种穿透高层抽象语法糖、直击底层运行本质的畅快感与知识解渴感。
## Bulletin - [00:00](https://www.youtube.com/watch?v=K0BjgYZbgfE&t=0s) 直接 await 协程并不会创建 Task,而是像普通生成器一样在当前 Task 上下文中同步深入调用;事件循环调度的最小单位始终是 Task,无法直接调度裸协程。 - [03:07](https://www.youtube.com/watch?v=K0BjgYZbgfE&t=187s) CPython 底层通过 GET_AWAITABLE 与 YIELD_FROM 字节码驱动 await,遇到未完成生成器时会回退程序计数器并退出当前帧,以便下次恢复执行。 - [06:51](https://www.youtube.com/watch?v=K0BjgYZbgfE&t=411s) Task 继承自 Future,其 __await__ 方法在任务未完成时直接 yield self 将自身抛出,使外层捕获并建立依赖链条。 - [09:40](https://www.youtube.com/watch?v=K0BjgYZbgfE&t=580s) 任务依赖通过 add_done_callback(self.__wakeup) 实现,当前任务在等待期间完全从事件循环中静默退出,直到被依赖方完成时通过 call_soon 重新排队唤醒。
## Key takeaways - [00:00](https://www.youtube.com/watch?v=K0BjgYZbgfE&t=0s) 勘误澄清:await 一个协程不会隐式将其封装为 Task,两者调度层级与控制权流转截然不同。 - [00:00](https://www.youtube.com/watch?v=K0BjgYZbgfE&t=0s) 通过 VizTracer 可视化调用栈可知,直接 await 协程是直接同步进入函数调用,而 create_task 会交出控制权并在事件循环下一次迭代中运行。 - [00:00](https://www.youtube.com/watch?v=K0BjgYZbgfE&t=0s) 事件循环调度的最小单位是 Task,事件循环无法直接调度裸协程,程序最顶层必须存在由 asyncio.run 等启动的根 Task。 - [03:07](https://www.youtube.com/watch?v=K0BjgYZbgfE&t=187s) GET_AWAITABLE 字节码通过底层 _PyCoro_GetAwaitableIter 检验对象,若为协程则直接返回,否则调用其 __await__ 魔术方法获取迭代器。 - [03:07](https://www.youtube.com/watch?v=K0BjgYZbgfE&t=187s) YIELD_FROM 字节码通过 PyGen_Send 反复推进生成器;生成器完成时提取 StopIteration.value 作为返回值并继续分发下一条指令。 - [03:07](https://www.youtube.com/watch?v=K0BjgYZbgfE&t=187s) 当被调用的生成器执行 yield 时,CPython 虚拟机会将当前帧的指令计数器回退一步并跳向退出逻辑,从而保留函数局部状态。 - [06:51](https://www.youtube.com/watch?v=K0BjgYZbgfE&t=411s) Task 本质上是 Future 的子类,两者在 await 机制中的行为同构,依赖的核心逻辑均定义在 Future.__await__ 中。 - [06:51](https://www.youtube.com/watch?v=K0BjgYZbgfE&t=411s) Future.__await__ 是一个生成器函数:未完成时执行 yield self 将自身向外抛出,已完成时直接 return self.result()。 - [09:40](https://www.youtube.com/watch?v=K0BjgYZbgfE&t=580s) 新建 Task 时,构造函数会通过 self._loop.call_soon(self.__step) 将初始单步推进逻辑注册到事件循环的下一次迭代中。 - [09:40](https://www.youtube.com/watch?v=K0BjgYZbgfE&t=580s) Task.__step 通过 coro.send(None) 推进内部协程,一旦捕获到被依赖方抛出的 Future/Task,便对其调用 add_done_callback(self.__wakeup)。 - [09:40](https://www.youtube.com/watch?v=K0BjgYZbgfE&t=580s) 等待中的 Task 在注册回调后不再向事件循环注册自身,会直接从事件循环中“消失”,将唤醒权完全托付给被依赖对象。 - [09:40](https://www.youtube.com/watch?v=K0BjgYZbgfE&t=580s) 被依赖方完成时通过 set_result 触发 _schedule_callbacks,使用 loop.call_soon 异步唤醒等待者而非就地同步执行。 - [09:40](https://www.youtube.com/watch?v=K0BjgYZbgfE&t=580s) 异步唤醒机制既保证了不同 Task 间 ContextVars 上下文环境的严格隔离,也避免了深层回调链霸占线程,确保了事件循环调度的公平性。
## Detailed walkthrough ### [00:00](https://www.youtube.com/watch?v=K0BjgYZbgfE&t=0s) 勘误与区分:await 协程并不等于创建 Task
视频开篇,码农高天调侃起作为日更创作者的压力,感叹自己每天更新还要被观众吐槽内容太水,于是决定顺应高呼声,做一期极其硬核的 Python AsyncIO 探究。调度系统(scheduling)包含的内容极其庞杂,因此本期内容聚焦于核心切片——`await` 机制。在正式剖析前,高天首先郑重勘误了自己在前一期视频中出现的一个关键技术失误,正如他所言:“知错能改,善莫大焉,当然其实你们没有任何一个人发现我这个错误,这个错误其实还是挺重要的,就是我上期视频里面说如果你 await 一个 coroutine 的话,他会把这个 coroutine 变成一个 task,对不对?这个事情是错的啊,await 并不会把一个 coroutine 变成一个 task。”
为了彻底证实并讲透这一原理,高天借助自己开发的性能剖析可视化工具 VizTracer(视频语音识别为 VTREASER / Vstracer)对比了两种写法的内部执行链路。在第一种最简单的代码中,`main` 函数中直接 `await asyncio.sleep(1)`。VizTracer 展现的调用栈非常直观:`main` 函数是直接以常规函数调用的方式进入了 `asyncio.sleep`,整个过程并没有调用 `create_task`,也完全没有交出控制权,所有步骤如同调用普通生成器一样同步执行,直到内部遇到阻碍才停住。紧接着,高天展示了显式通过 `asyncio.create_task` 包装后再 `await` 的轨迹:此时 `main` 在当前循环内创建 Task 并直接返回,等到事件循环的下一次迭代(`_run_once`)时,`sleep` 任务才真正开始运行。
高天强调,虽然直接 await 协程和先创建 Task 再 await 最终的业务运行结果看似相同,但两者的调度层级和控制权流转截然不同。在 Task 中直接 await 一个协程时,当前 Task 不会交出控制权,而是像调用生成器那样等待协程返回(在 `sleep` 中实际是新建并返回了一个底层的 Future)。相反,当新建 Task 时,新任务被独立注册到事件循环中,当前 Task 挂起并将控制权完全归还给事件循环,随后由事件循环重新调度执行新 Task。理解这一区别是后续深入源码的基石。
- 事件循环调度时的最小单位始终是 Task,事件循环本身无法直接调度裸协程(coroutine),因此在程序的最顶层必须存在一个根 Task(例如由 `asyncio.run` 启动生成的 Task)。 - 当调用 `await coroutine` 时,协程是在当前所属的 Task 上下文中同步向深处推进,直到触底遇到真正的挂起对象。
---
[03:07](https://www.youtube.com/watch?v=K0BjgYZbgfE&t=187s) 字节码探秘:GET_AWAITABLE 与 YIELD_FROM 的工作原理
要彻底弄清楚 `await` 的运行机制,最底层的途径依然是观察 Python 虚拟机的字节码(Bytecode)。通过分析可以看到,在 `await` 表达式所在的代码块中,最关键的核心字节码只有两条:`GET_AWAITABLE` 与 `YIELD_FROM`。高天解释道,`await` 后面所跟的对象通常分为三种:协程(coroutine)、Task 或者是 Future。针对 `GET_AWAITABLE` 指令,CPython 底层调用的是 `_PyCoro_GetAwaitableIter` 函数。该函数的源码注释写得非常直白:如果目标对象本身就是一个协程,则直接返回该对象;否则,它会去寻找并调用该对象的 `__await__` 魔术方法(即底层属性槽 `tp_as_async.am_await`),最后将拿到的可等待迭代器推入运行栈顶。
真正承担起“决定继续向下走还是就地挂起”逻辑的重头戏,在于紧随其后的 `YIELD_FROM` 指令。该指令在虚拟机主循环 `_PyEval_EvalFrameDefault` 中本质上是在反复调用底层生成器的 `send` 方法(`PyGen_Send`)。高天指出:“在我们使用 AsyncIO 的时候永远是走这个路线,就是我们 await 的东西要么是一个 coroutine,要么就会是一个 generator。”当被调用的生成器结束运行时(无论是执行了 `return` 还是抛出了 `StopIteration`),底层获取到的返回值(`resval`)都会是 `NULL`。在 CPython 的执行逻辑中,虚拟机会利用宏提取出 `StopIteration` 异常中的 `value` 属性作为该表达式的最终返回值,将其压入栈顶,随后调用 `DISPATCH()` 继续执行下一条字节码。这就解释了为什么在 Python 语法糖中,协程函数的 `return` 在底层等价于抛出携带该返回值的 `StopIteration`。
而当生成器尚未结束,而是执行了 `yield` 操作时,情况则完全相反:此时从底层返回的 `resval` 并不是 `NULL`。为了在视觉上呈现出“函数在中间停住并交出控制权”的效果,CPython 虚拟机会将当前执行帧的指令计数器(Program Counter / Instruction Pointer)向前回退一步(即执行 `f->f_lasti--` 之类的指令倒退操作)。随后代码跳出常规的 `DISPATCH()`,走向 `goto exiting` 逻辑,直接带着 `yield` 出来的对象退出当前执行栈帧并返回。如此一来,当前函数的局部状态被完整保留;当外部在未来的某一刻重新唤醒并调用这个函数时,虚拟机依然会从这条 `YIELD_FROM` 字节码指令处无缝接着往下执行。
```mermaid flowchart TD accTitle: YIELD_FROM Execution Flow accDescr: CPython 虚拟机在执行 YIELD_FROM 时针对协程结束与挂起的两条不同分支处理逻辑。
subgraph CoreOp ["执行 YIELD_FROM / PyGen_Send"] A["调用生成器 send"] --> B{"生成器是否结束?"} end
subgraph FinishedPath ["分支一:生成器已结束"] B -- "是 (return / StopIteration)" --> C["resval == NULL"] C --> D["提取 StopIteration.value 压入栈顶"] D --> E["DISPATCH (执行下一条字节码)"] end
subgraph YieldPath ["分支二:生成器 Yield 挂起"] B -- "否 (yield 出对象)" --> F["resval!= NULL (获得被挂起对象)"] F --> G["程序计数器回退 (下次仍由此指令进入)"] G --> H["goto exiting (保存帧状态并向外返回)"] end ```
---
[06:51](https://www.youtube.com/watch?v=K0BjgYZbgfE&t=411s) Task 与 Future 的本质:__await__ 如何挂起自身
解构完字节码的停顿与恢复机制后,问题转到了上层结构:`await` 是如何处理任务间依赖的?高天指出,由于 `await` 后面只能跟协程、Task 和 Future 这三样东西,而协程本质上只是最基础的生成器,要搞懂依赖必须翻看标准库源码中的 `asyncio.Task` 和 `asyncio.Future`。查看源码可以发现,`Task` 实际上直接继承自 `Future`,`Future` 是 `Task` 的基类(Base Class)。
高天带领大家定位到了 `Future` 源码中的关键魔术方法——第 281 行的 `__await__`。正如前面分析 `GET_AWAITABLE` 时所阐述的,如果一个非协程对象被 `await`,虚拟机就会调用它的 `__await__` 方法。翻开 `Future.__await__` 会发现,它本身就是一个典型的生成器函数。其核心代码逻辑极为精简: 1. 如果当前 `Future` 尚未处于完成状态(not done),它就会执行 `yield self`,把自己整个实例对象直接 `yield` 出去; 2. 如果已经完成,它则直接执行 `return self.result()`。
高天强调,正因如此,`Task` 和 `Future` 在 `await` 场景下的行为表现与协程完全同构。无论当前在 `await` 的是一个协程、Task 还是一个底层的 Future,对于外部的 `YIELD_FROM` 来说都只有两种可能:要么对象通过 `yield` 将自身抛了出来,通知外层需要挂起;要么抛出 `StopIteration` 并带回最终的运算结果。当这一层机制清晰之后,并发与任务依赖的底层图景便只剩下最后一块拼图:当一个 Task 把自己 `yield` 出来导致外部挂起后,它又是怎样在完成时回过头来精准唤醒那些等待它的任务的。
---
[09:40](https://www.youtube.com/watch?v=K0BjgYZbgfE&t=580s) 依赖机制揭秘:__step、add_done_callback 与事件循环调度
为了讲透 Task 之间的相互等待与并行调度,高天深入分析了 `Task` 的初始化方法 `__init__` 和核心驱动函数 `__step`。当一个新的 Task 被创建时,构造函数内部会执行 `self._loop.call_soon(self.__step)`,这意味着它在向事件循环注册:请在下一个循环迭代(Iteration)中尽快执行本任务的 `__step` 方法。高天在此处再次提醒观众,事件循环能够调度的最小单元永远是 Task,在整个调用链的最顶端,一定存在一个运行着 `__step` 的 Root Task(例如 `main`)。
随后高天重点分析了 `Task.__step` 函数的内部实现。在第 256 行附近,该 Task 开始执行内部协程的驱动操作:`result = coro.send(None)`。这里同样面临两种结果: - 如果该协程顺利执行完毕,它会抛出 `StopIteration`,此时代码进入异常捕获分支,调用 `self.set_result(exc.value)`,将生成器 `return` 出来的结果正式写入当前 Task; - 如果协程未能结束,说明它在向下执行时卡在了某处(比如 `await` 了一个其他的 Task 或 Future),此时 `send(None)` 就会接收到被依赖方通过 `yield self` 传递出来的对象。这个被 `yield` 出来的依赖对象就被保存在 `result` 变量中。
此时,被阻塞的任务执行了极其关键的一步操作——调用 `result.add_done_callback(self.__wakeup)`。对此,高天形象地总结了任务间的协作逻辑:“也就是说当这个 Task 发现有另外一个 Task 完成他才能继续的时候,也就是他 await 了一个其他的 Task 或者 Future 的时候,他会在那个 Task 或者 Future 上标记一下,他会告诉他依赖的那个 Task 或者 Future 说等你完成的时候你来叫我,然后这个 task 就从 event loop 里消失了。”在注册完回调之后,当前 Task 没有执行任何将自己重新放入事件循环的操作(没有调用 `call_soon`),它完全静默并交出了执行权,将自己的唤醒契机完全托付给了它所依赖的那个对象。
那么这个 `callback` 何时被触发呢?当被依赖的任务终于执行完毕、调用自身的 `set_result` 时,它会调用底层 `Future` 的 `_schedule_callbacks` 方法。该方法会遍历身上收集的所有回调函数,并调用 `self._loop.call_soon(cb, context=...)` 将它们重新挂回事件循环中,从而触发原先等待者的 `__wakeup`,进而让原任务再次运行 `__step` 接着往下走。针对为什么不直接就地同步调用回调函数,而是非要再走一次 `call_soon` 让事件循环去排队,高天明确指出了两大核心架构原因: 1. **上下文管理(ContextVars)**:不同的 Task 往往绑定并运行在各自独立的上下文环境中,其局部变量和环境隔离要求它们必须由事件循环分派执行,不能直接在当前栈帧内穿透执行。 2. **调度公平性(Fairness)**:如果允许直接递归执行深层回调链,可能会导致某个调用栈过长过深,从而长时间霸占执行线程,导致事件循环中其他已经准备就绪的任务迟迟得不到运行。让每个回调任务都走一次事件循环调度,能有效均摊延迟,保证所有任务的调度公平。
视频最后,高天总结道,本期硬核梳理彻底扒开了 `await` 关键字及其在 Task 间构建依赖关系的黑盒机制。虽然因为篇幅限制没有深入涉及事件循环整体的任务调度分配算法,但已经把协程底层最神秘的核心切面彻底点亮,并期待未来有机会再针对生成器和完整调度算法展开细致拆解。
- `Task` 在创建时通过 `loop.call_soon(self.__step)` 将初始推进逻辑放入事件循环。 - 当任务依赖未就绪时,它通过 `add_done_callback(self.__wakeup)` 把自己绑定到被依赖方的完成通知链上,自身从事件循环活跃列表中退出。 - 被依赖方就绪并执行 `set_result` 后,利用 `_schedule_callbacks` 配合 `loop.call_soon` 异步唤醒所有等待者,兼顾了上下文隔离与事件循环调度的公平性。
## Notable quotes > [00:00](https://www.youtube.com/watch?v=K0BjgYZbgfE&t=0s) await 并不会把一个 coroutine 变成一个 task。 — 码农高天
> [03:07](https://www.youtube.com/watch?v=K0BjgYZbgfE&t=187s) 在我们使用 AsyncIO 的时候永远是走这个路线,就是我们 await 的东西要么是一个 coroutine,要么就会是一个 generator。 — 码农高天
## Mentioned 码农高天, Python, AsyncIO, CPython, VizTracer
## Video description 这期视频详细解说一下await背后的所有机制,把asyncio的一个角落彻底点亮!让协程不再那么神秘!
[Source](https://www.youtube.com/watch?v=K0BjgYZbgfE)