章节目录

第十一章:把“下一步”做成一个值

本节阅读量:

前十章已经让这门小语言拥有整数、函数、闭包、record、可变状态、copying GC 和统一的寄存器分配后端。现在我们做一篇选修综合篇:实现一个很小、但真的可以暂停和继续运行的 generator。

先看本章的主例子:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
(let g
  (coroutine
    (let x (yield 40)
      (+ x 2)))
  (let paused (resume g)
    (let done (resume g)
      (if (eq? (get paused 0) 0)
          (if (eq? (get paused 1) 40)
              (if (eq? (get done 0) 1)
                  (get done 1)
                  0)
              0)
          0))))

它看起来像一个函数在中途停下,稍后又从原处继续:

1
2
3
创建 g                         不执行 coroutine body
第一次 (resume g)              暂停在 (yield 40),得到 (record 0 40)
第二次 (resume g)              让 yield 作为 40 继续,完成为 (record 1 42)

先把它跑起来:

1
2
3
cd code/11_coroutines
make
./mini run examples/coroutine_protocol.lang

与第九、十章相同,make 会同时生成 mini 和独立的 C++ GC runtime object gc_runtime.o。

输出是:

1
42

这里的 42 同时检查了两次 resume 的协议:第一次的状态字段是 0、产出值是 40;第二次的状态字段是 1、完成值是 42。

本章只做一种受限协程

本章实现的是单向、非对称、generator 风格的无栈协程。它有三种固定特殊形式:

1
2
3
(coroutine body)
(yield expr)
(resume expr)

coroutine 创建 generator 值,yield 产出一个值并暂停,resume 继续一个 generator。它不是完整的协程系统:没有向 generator 发送值、没有对称 transfer、没有调度器、线程、异步 I/O、取消、异常传播或另一套机器栈。

这个范围很小,却足以回答一个重要问题:暂停以后,原来函数里那些局部变量、分支位置和后续语句到哪里去了?

本章的答案是:它们不留在冻结的机器栈里,而是由一个 closure 保存为“下一步”。

1
2
3
4
5
coroutine value
  -> resume closure
       -> state cell
            -> 当前 step closure
                 -> 捕获的局部值与后续控制流

第七章的 closure 已经会保存外层环境;第八章的 cell 已经能让某个位置改指向新值;第六章的 record 已经能作为数据协议;第九章的 GC 已经会扫描这些对象;第十章的后端已经能把它们保存在 root slot。第十一章不新发明运行时对象,而是把这些能力接起来。

resume 的结果协议

每次 resume 都返回已有的二字段 record:

record 含义
(record 0 value) 本次在 yield 处暂停,字段 1 是产出的值
(record 1 value) coroutine 已完成,字段 1 是 body 的最终值

完成不是重新开始。运行:

1
./mini run examples/coroutine_done_repeat.lang

示例先让 (coroutine 42) 完成,再重复恢复它,并用 eq? 检查两个结果是否指向同一个完成 record。输出仍是:

1
42

缓存完成 record 有两个好处:body 的副作用不会重跑,完成后的结果也有稳定的对象身份。

创建不等于执行

coroutine 的 body 不会在创建时运行。下面的程序在创建 g 后立刻检查外层 touched,因此应得到 42:

1
./mini run examples/coroutine_lazy.lang

对应源码中的 set! 放在 body 内部;只有第一次 resume 真正调用 initial step 时,它才会执行。

两条已有实现路径继续一致

本章保留 surface AST,方便读者看见自己写下的 Coroutine、Yield 与 Resume:

1
./mini ast examples/coroutine_protocol.lang

随后,新加的 core 命令显示去糖结果:

1
./mini core examples/coroutine_protocol.lang

它会出现 $coro.state.*、Lambda、Set、Record 和普通 Call,但不再出现 Coroutine、Yield 或 Resume。$coro.* 是编译器生成的内部名字;用户语言的普通 identifier 仍只能由英文字母组成,因此不会冲突。

之后两条路径都只处理 core AST:

1
2
3
4
5
6
7
source
  ↓ parser
surface AST  -- mini ast --> Coroutine / Yield / Resume
  ↓ validation + coroutine lowering
core AST     -- mini core --> lambda / set! / record / call
  ├─ interpreter ------------------------------> Value
  └─ structured IR -> register allocation -> x86-64 + GC

因此解释器不需要偷偷维护另一套 coroutine machine,汇编运行时也不需要新的 coroutine tag。run、ir、alloc 和 compile 都先执行同一份 validation 和 lowering。

编译并观察原生程序

先看 lowering 后的 IR:

1
./mini ir examples/coroutine_protocol.lang

开头会出现 state cell、创建 closure 和写回 state 的操作;后面则是已有的 check-closure 与 call。这正是“resume 最终只是调用当前 step closure”的机器层面形状。

生成汇编并运行,在 Linux、WSL 或 Intel Mac 上:

1
2
3
4
./mini compile examples/coroutine_protocol.lang -o out.s
c++ out.s gc_runtime.o -o out
./out
echo $?

最后一行应为:

1
42

Apple Silicon Mac 需要生成和链接 x86-64 程序:

1
2
3
4
./mini compile examples/coroutine_protocol.lang -o out.s --target macos
c++ -arch x86_64 out.s gc_runtime.o -o out
./out
echo $?

这仍然不是 mini 替你链接:compile 的职责始终只是输出 x86-64 System V ABI 的 AT&T 汇编。第九章以来的 C++ collector 继续由本章 make 构建成 gc_runtime.o;out.s 必须与它一起链接。

这一章要回答什么

我们按下面的顺序前进:

  1. 定义三种新语法和每次 resume 的 result protocol;
  2. 说明哪些 yield 位置可以被本章安全地改写;
  3. 在 AST 中保留 surface 形状,再把它降到旧语言的 core AST;
  4. 一步步展开一个 yield 如何保存 continuation closure;
  5. 用解释器、IR、GC 和第十章后端确认这不是纸上的变换。

核心句子始终是:暂停不是冻结机器栈,而是把“接下来还要做什么”保存成一个可以再次调用的值。


10.8 常见错误与练习

上一节

11.1 语言:一次 `resume` 只走到下一个边界

下一节