码农高天拆解Python协程底层:await并不创建Task

【python】await机制详解。再来个硬核内容,把并行和依赖背后的原理全给你讲明白 — 码农高天

码农高天

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)

中文简报

码农高天拆解Python协程底层:await并不创建Task await操作在当前任务上下文中同步推进生成器直至遇到未完成对象,依赖关系则由Future回调与事件循环调度驱动以保障上下文隔离与公平性。 视频首先勘误了“await 协程会自动将其转为 Task”的常见误区,并通过性能剖析工具 VizTracer 展示了直接 await 协程与显式创建 Task 在调用栈与控制权交接上的本质差异。随后,高天深入 CPython 源码,拆解了 GET_AWAITABLE 与 YIELD_FROM 这两条核心字节码如何驱动生成器的 send 方法,并利用指令计数器回退实现函数的挂起与恢复。最后,视频分析了 asyncio.Future 与 asyncio.Task 的源码实现,详细揭示了未就绪对象如何通过 yield self 挂起、借助 add_done_callback 建立依赖通知链,并依靠事件循环的 call_soon 机制实现兼顾上下文隔离与调度公平性的异步唤醒全流程。 直接 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)

Reading summary

码农高天在本期硬核剖析中勘误指出,await 协程并不会隐式创建 Task;通过分析 CPython 字节码以及 Task/Future 的内部源码,他揭示了 await 机制通过生成器 yield 挂起与回调链唤醒构建任务依赖的底层逻辑。 勘误:await 协程与创建 Task 的调度差异 (0:00) 高天首先澄清了此前视频中的错误观点:直接 await 一个协程并不会自动将其包装成 Task。借助性能分析工具 VizTracer 对比可知,在主协程中直接 await 协程时,程序以常规同步调用的方式沿调用栈深入,当前 Task 既不交出控制权,也没有触发事件循环的新任务注册。而只有显式调用 asyncio.create_task 时,新任务才会被独立注册到事件循环中,当前 Task 随后交出控制权并在后续的循环迭代(_run_once)中由事件循环调度该新任务。 高天强调,事件循环调度的最小单位始终是 Task,无法直接调度裸协程,因此程序入口必须由 root Task(如 asyncio.run 创建的任务)作为起点。直接 await 协程实际上是在当前所属的 Task 上下文中一路同步推进,直至触碰到底层真正的挂起对象。 字节码层面的挂起与恢复:GET_AWAITABLE 与 YIELD_FROM (3:07) 在 CPython 虚拟机层面,await 语法主要编译为 GET_AWAITABLE 与 YIELD_FROM 两条字节码指令。执行 GET_AWAITABLE 时,底层通过 _PyCoro_GetAwaitableIter 检验目标:若对象为协程则直接返回,否则调用其 __await__ 魔术方法获取可等待迭代器。紧随其后的 YIELD_FROM 则通过 _PyEval_EvalFrameDefault 主循环反复调用该生成器的 send 方法。 当生成器运行结束(执行 return 或抛出 StopIteration)时,底层获取的返回值为 NULL,虚拟机会提取 StopIteration 的 value 属性压入栈顶并调用 DISPATCH() 继续执行下一指令。相反,当生成器执行 yield 时,返回值不为 NULL,虚拟机会将当前帧的程序计数器向前回退一步并退出当前执行栈帧(goto exiting)。这种回退机制保证了局部状态得以保留,使外部下次驱动该函数时能精确从同一条 YIELD_FROM 指令继续推进。 Task 与 Future 的同构性与 __await__ 挂起机制 (6:51) 在 AsyncIO 上层抽象中,Task 继承自 Future,二者在被 await 时的表现形式与协程同构。源码显示,Future 内部定义了生成器形式的 __await__ 方法:若当前 Future 尚未完成(not done),该方法会直接执行 yield self 将自身实例抛出;若已经就绪,则直接返回其计算结果(return self.result())。 这意味着无论是协程、Task 还是底层的 Future,在面对 YIELD_FROM 指令时都遵循统一逻辑:要么通过 yield 抛出自身以通知外层需要挂起,要么抛出携带结果的 StopIteration 结束调用。这为并发调度中捕获依赖对象提供了统一的接口表现。 依赖闭环:__step、回调链与公平调度机制 (9:40) 任务之间的依赖建立依托于 Task 的内部方法 __step 与回调机制。Task 初始化时会通过 loop.call_soon(self.__step) 将自身执行推入事件循环。当进入 __step 并调用内部协程的 send(None) 时,若被依赖对象未完成,其通过 yield self 抛出的依赖实例会被捕获,当前任务随即执行 result.add_done_callback(self.__wakeup),以此委托被依赖方在就绪时唤醒自己,随后该任务退出当前事件循环队列。 当被依赖的任务完成并调用 set_result 时,它会通过 _schedule_callbacks 遍历回调,再次使用 loop.call_soon 异步触发等待者的 __wakeup。高天指出,没有直接在当前栈帧内同步执行回调,原因在于两点:一是维护 ContextVars 的上下文隔离,避免跨任务环境污染;二是保证调度公平性,防止过长过深的回调链长时间占用执行线程,确保已就绪的任务均摊执行延迟。 本期内容聚焦剖析了 await 关键字及其基于生成器与回调所构建的任务依赖原理,但未展开涉及事件循环整体的任务调度分配算法,高天计划在后续内容中对生成器底层机制与完整调度体系作进一步拆解。

Source quotes

[0:00] 码农高天: await 并不会把一个 coroutine 变成一个 task。 [3:07] 码农高天: 在我们使用 AsyncIO 的时候永远是走这个路线,就是我们 await 的东西要么是一个 coroutine,要么就会是一个 generator。

Original transcript

[0:00] 现在做视频太不容易了我一个日更up主还要被人说做的东西太水了那今天我们做一期呼声很高的内容啊就是这个AsyncIO的探究本来我是想把scheduling这个部分一次性的说完后来发现这里面的内容实在是太多 太杂 太长了所以我们这期视频只关注一个点就是await我们来研究一下Await背后的机制到底是什么它是如何做到一步的又是如何实现这些依赖的呢他怎么那么聪明 知道要等待什么任务 然后让那些任务先运行的呢好 在开始之前 我先纠正上期视频里我犯的一个错误知错能改,山没大焉当然其实你们没有任何一个人发现我这个错误这个错误其实还是挺重要的就是我上期视频里面说如果你await一个coroutine的话他会把这个coroutine变成一个task对不对这个事情是错的啊Await并不会把一个coroutine变成一个task它就会像一个正常的生成器一样相当于调用这个生成器然后这一些步骤都是同步的直到他遇到一个卡让他没法继续进行那为了证实我这个理论呢我们用VTREASER来看一下我在左边写了一个非常简单的函数是个main就调用了一个sleep对不对如果await一个coroutine会建立一个task的话我们应该能在某个地方看到create task这个事情我们看一下VTRAITOR结果我们看屏幕的中间这个main函数实际上是直接调用了这个async sleep而不是建立了一个task让async sleep在另外一个task里面运行他在等待这个task为了与之区分我们尝试一下真正的如果我们create了一个task然后在await是什么样子的同样的用Vstracer跑一下我们可以看啊如果新建了一个Task应该是这样的他在main里面先create了一个task然后他就返回了在下一次Iteration的时候注意下一次Run Once的时候这个Sleep才运行那这两者是有区别的啊当你在task中await一个coroutine的时候这个task并不会交出控制权它会调用coroutine就像调用一个生成器一样然后等coroutine返回一个东西我们可以看到其实sleep这里是新建了一个future然后返回的所以在main这边是完整的等待了co routine的调用并且在await那个future 而当你新建task的时候它会新建一个task 并且把这个task注册到event loop里然后main就结束了 它就把控制权交还给event loop了然后是event loop重新调用的sleep 这两种写法虽然最后的结果是一样的但是它们的机制是不一样的 大家注意一下好 那为什么要在这期视频最开始的时候强调这件事呢因为我们在后面的讨论里呢有时候需要把这两种情况分开来讨论就是如果这个东西是一个coroutine的话会怎么样如果这个东西是一个task或者是一个future的话会怎么样但是这里大家注意一下啊event loop在调度的时候 它的最小单位依然是taskevent loop依然没有办法执行一个coroutine所以说它的顶层一定是一个task那拿我们这个例子来说呢它现在这个顶层的task是main就是asyncIO.run还是会建立一个task那区别只在于我们是两个task还是一个task里面有两个coroutine好 那我们老规矩啊 遇失不绝 先看bitecode对不对这个bitecode是这三行 确实说就是8跟12这两个bitecode是最重要的我们已经知道啊 wait home这个东西呢可以是一个coroutine可以是个test或者future那么getAwaitable跟yieldfrom这两个bycode就是我们理解await是怎么工作的核心那getAwaitable这个部分呢说实话比较简单它的核心函数就是这个函数啊叫PiCoralGetAwaitfulEater它的注释写的很清楚如果O是一个coroutine的话它直接返回O否则的话他返回的是下面这一串对吧 TPS async, AM await这个是什么呢这个就是下号线下号线await大家只要记住getAwaitable是把这个东西放到了站顶就可以了那Ult from这边稍微复杂一些我帮大家简化一下在我们使用asyncIO的时候永远是走这个路线就是我们await的东西要么是一个coroutine 要么就会是一个generator那这个py gensend 如果你知道什么是generator的话它的本质就是调用了一下generator sam的函数这个生成器也是我们以后有机会再讲啊如果你不知道它是什么东西的话简单来说如果这个生成器里面EUD了一个东西会把它保存到这个Red Valley如果这个生成器结束了包括return了或者是raise了一个stop iteration它的write value都会是一个null并且这个return值会被保存到这个stop iteration这个exception里好 那我们看下面这个部分 下面这个部分就是asyncIO的控制部分的核心机制也就是他怎么知道我要在这等 还是接着往下运行我们看这个if block前面已经提到了如果 [5:00] 这个生成器结束了不管这个生成器是coroutine的好还是task或者是这个future也好只要它结束了它的readval就是一个no在结束的情况下它会把返回值保存到这个Valley注意这用的是fetch sub iteration value在asyncdef里当你return什么东西的时候是等价于raise了一个stop iteration然后把stop iteration里面的value设置成返回值的它是一个预法堂然后这里注意2246行他把这个值给设成了占顶然后2247行dispatchdispatch是什么呢dispatch就是continue那么我们现在在这个Evaluate Frame函数它是一个很大的无限循环 continue的意思就是继续运行下一个bytecode也就是说 当你这个await后面这个生成器结束了的时候它就会继续运行下一个bicode那么视觉上来看就是接着往下跑了好 如果redwell不是now说明什么说明这个生成器yield出来了一个东西这个东西是什么 我们等会介绍去看完代码但是你就知道当这个生成器不是return 而是yield的时候交运行下面从2251到2255这一段代码那这里最重要的是这一行是2252这一行如果你懂汇编的话 相当于调了一下program counter就是我这一个frame上一个运行的instruction往回调了一个那我们从比较high level的理解就是说下一次运行这个程序的时候还要运行一下这个instruction或者说还从这个instruction开始运行就我们正常的程序不是一个instruction一个instruction运行吗他现在运行完了这个instruction也就是yieldfrom之后他又把这个instruction的位置往回调了一个然后他保存这个函数的现状你在调用这个函数的时候还要从这个instruction开始希望我讲明白了紧接着225这个非常重要注意这不是dispatch了这是go to exiting那Edit是什么东西呢看这里啊本质上就是我现在就要返回了我不把我这个程序运行完了我直接返回我的redval这个redval是什么是我们刚才yield出来那个东西对不对那这个部分的本质上就是python生成器的代码这你就可以理解python的生成器也好啊或者是迭代器也好啊它是如何对吧函数运行一半它可以返回一个值然后下次你调用它的时候还接着运行它就是这么来的 当然Await也是使用了这个架构讲到这里,我们其实只说了wait是怎么从函数中间停住并且返回 而且返回的是一个什么值的那它的依赖是怎么实现的我们就要去看AsyncIO的原代码了好 那我们之前说过await后面只能跟三种东西对不对第一个是coroutine第二个是task第三个是future我刚才也解释了coroutine的本质其实就是一个很简单的生成器它可以yield一个东西可以return一个valuereturn的时候相当于raise了一个stop iteration我们看一下task和future这里是这个AsyncIOTask这个class我们可以看到Task实际上是继承了Future的也就是说Future是Task的Base Class那我们去看一下 future 这里呢是这个 future class啊我们首先找到一个比较重要的 magic method在这里啊我们看281行这个下号线下号线 await我们之前在讲get awaitable的时候说过如果await后面是一个coroutine的话直接返回这个coroutine否则的话返回这个东西的点下游线下游线 wait 对不对那我们可以看到Task和Future的Away檀数都是一个生成器那这个函数也比较好读如果我还没有结束的话我是把我自己给yield出去否则的话我会return with result那这个跟coroutine的behavior基本是一致的也就是说当我await一个awaitable不管它是一个coroutine它是一个task还是一个future都是两种可能性第一个他 出来了一个东西第二个他raise回了一个stop iteration并返回了一个返回值我们刚才也讲了Ilde From会根据这两种不同的情况做两种不同的选择是直接返回这个值还是继续下下进行好 那我们回到Task这里 我们要开始说Eli这个事情了他是怎么做到并行和互相等待的我们看这是TaskInit函数当你建立一个task的时候注意这块会有一个loop的call soon它会让event loop在下一个iteration的时候跑一下self点下右线下右线step这个函数所以说这个下右线下右线step函数就是整个的控制流里面非常重要的函数好 那我们看一下这个函数 这个函数也是比较长我们挑比较重点的部分来说还记不记得我们前面强调过event loop [10:00] 他们能控制的单位最小单位是Task也就是说你在最顶上永远会有一个Task在执行那我们刚才举的例子所谓root task就是main对不对也就是说你在最最顶上的时候永远会有一个task运行了step这个函数然后来看256行这个task会运行叫coroutine.send none前面提到过 send 是一个生成器的method 对不对那运行coroutine我们前面说了有两种情况对不对第一个就是出stop iteration了第二个就是得到了一个才EAR回来的东西那我们在刚才看他Future实现的时候会知道他返回的是他Self也就是那个Future或者是那个Task因为Task继承了Future不管是 future 还是 task 我们要记住这个 result 相当于是卡住的那个 future 或者卡住的那个 task好 也就是说 如果这个coroutine已经完成了的话它会运行265这行函数他会把stop iteration里面的value给做一个set result我们也说过这个value就是你在生成器里面return的那个值如果他还没有完成他会把卡住的那个future或者是task保存在result里面也就是说result里面现在保存的是它的依赖需要这件事完成了我才能继续那么它实际上就会调用这一段代码这里比较重要的就是这个Add then call back就是他告诉result这个future或者是这个task说等你完成的时候 你记得叫我一下这个self wake up那在说完了这件事之后他就没消息了我们可以看到后面没有任何再重新schedule这个task的代码了也就是说当这个Task发现有另外一个Task完成 他才能继续的时候也就是他await了一个其他的Task或者Future的时候他会在那个Task或者Future上标记一下他会告诉他依赖的那个Task或者Future说等你完成的时候 你来叫我然后这个task就从event loop里消失了因为他并没有在这里面再去调用一个selfloop.call soon之类的函数他是把自己的 wake up作为一个callback放到了它依赖的那个Future或者是Task上面OK 那我们来聊一下callback的实现啊 我们刚才已经提到了这块有一个result.add them callback对不对大家一定要记住result是block的那个future或者是task就是需要先完成的那个我们看这里是爱丹callback也比较简单 主要是这块append了一个function和contextcontext我们也不做过度的介绍 刚才我们其实已经跳过去好几个了 它核心就是保存上下文那这个self.callbacks什么时候被用到呢还记不记得我们之前说过的这里当这个coroutine也好task也好future也好完成的时候它会raise一个stop iteration然后这个时候呢它会做一个set result那所谓Super Set Result就是用它的Base Class也就是Futurethe set resultset result在这里注意在这里靠了一个schedule callbacksschedule callbacks就是把他身上所有的callbacks完成了之后剩下的可以开始的task都给schedule上这里用的也是call soon 注意一下啊他不是直接调用的callback而是告诉event loop说这几个callback可以执行了为什么要这样做呢两点原因啊第一个就是我们刚才说的context就是上下文管理就是这些task实际上是运行在不同的上下文之下的就是它的local variable或者global variable或者这些东西吧它都是不一样的所以他们要分开运行第二个其实是跟调度有关就是如果你一直在这么直接运行callback的话呢你会产生一个不公平分配的问题就是你有一个callback 可能非常非常深 然后一通往回运行这个过程非常长于是耽误了其他很多已经准备好的任务他们就执行不了了像这样每一个callback也好每一个task也好都让event loop去schedule相对来说公平性就会稍微好一些也就是每一个任务的延迟会稍微低一点好那这就是我们今天聊的全部内容啊主要是await这个关键字背后的实现还有不同的task之间是怎么进行互相依赖的那这个部分内容呢确实比较多比较杂比较难啊如果大家有没有看懂的地方呢也欢迎弹幕或者留言告诉我然后我争取再讲的清楚一些当然我们今天是几乎完全没有碰event loop是怎么把这些task做调度跟分配的因为async这边的内容太多了啊如果我想讲的比较细的话呢那每一次就讲的内容相对来说就少一些所以咱们只能未来有机会再说了啊好 那这期视频就到这里 喜欢的朋友点一下关注 别忘了一键三连哦请不吝点赞 订阅 转发 打赏支持明镜与点点栏目