分配器:在两个半区之间轮换
本节阅读量:对象图告诉我们“什么必须保留”,但正常执行时最常见的动作仍然是创建新对象。若每次分配都立刻遍历整张对象图,程序会慢得难以理解。
第九章把工作分成两种路径:
|
|
大多数分配走第一条短路径;只有当前半区放不下新对象时才启动 GC。
两个各 4 KiB 的 semispace
src/runtime/gc.cpp 把 collector 的状态保存在 C++ runtime 内部。mini_gc_init 启动时向宿主 malloc 请求两块内存:
|
|
kSemispaceBytes 是 4096。allocate_bytes 调用 std::malloc,并检查返回值与 8 字节对齐;失败时统一进入 mini_gc_abort()。这些细节由编译 gc.cpp 的 C++ 工具链处理,不再由汇编 emitter 分别拼出 Linux 的 malloc 和 macOS 的 _malloc。
两块区域各有一个名字:
|
|
它们各 4096 字节,总共向宿主申请 8192 字节对象空间,但任一时刻可用的活动容量仍只有一个半区的 4096 字节。copying collector 用一半空间作为下一次搬家的目的地,这是换取简单算法的固定成本。
此外,运行时还会单独申请 shadow root stack;它不占这 8192 字节,下一节再讲。
bump allocation 只做一次加法
from-space 中有三个关键地址:
|
|
申请 size 字节时,快速路径只需:
|
|
这里返回的是对象起始处的原始地址。所有对象大小都是 8 的倍数,两个半区的起点也满足对齐,因此 bump pointer 每次移动后仍然对齐,低三位可以安全用于 Value tag。
分配器不会替具体对象写 header。它只切出一段足够大的空白空间;调用者随后写入 record、closure 或 cell 的布局。
mini_alloc(size, root_top) 的 C ABI 约定
生成的汇编与 C++ runtime 需要互相调用,却不能依赖 C++ 编译器修饰后的函数名。gc.h 因此把边界声明为 extern "C":
|
|
extern "C" 固定了链接名称;它没有把实现语言改成 C。函数体仍然位于 gc.cpp,可以使用 C++ 的类型、名字空间和辅助函数。
生成代码通过固定寄存器调用分配器:
| register | 调用前 | 返回后 |
|---|---|---|
%rdi |
请求的字节数 | 无约定 |
%rsi |
shadow root stack 的第一个空地址 | 无约定 |
%rax |
无约定 | from-space 中未加 tag 的原始地址 |
也就是:
|
|
root_top 让分配器在慢路径中知道需要扫描多少 root。即使大多数调用不触发 collection,也必须每次都传,因为调用方不能预先知道剩余空间是否足够。
压力程序创建八字段 record 时,当前 emitter 生成下面这些关键行;中间七个同形的字段写入在这里省略:
|
|
72 是 header 加八个字段的总大小。2049 来自:
|
|
mini_alloc 返回时 %rax 仍是 raw address。emitter 写完 header 和字段,最后才执行 orq $4, %rax,把它变成源语言能够保存的 tagged heap Value。
在调用分配器以后还要使用、并且可能是堆引用的字段临时量,必须在调用以前拥有 root home。若这次调用恰好触发 GC,home 中的 record 或 closure pointer 会被改写;分配返回后从 home 重新读取,拿到的才是最新地址,不能把旧地址只留在普通临时寄存器里。整数字面量不需要为此单独占槽,因为 emitter 可以在返回后直接重新生成同一个 immediate;上面的八个 n 则是 IR 临时量,所以从 root slots 取回。
空间不足时先收集,再重试一次
慢路径现在就是一段可以直接阅读和单独编译的 C++:
|
|
collection 会把仍然可达的对象复制到原来的 to-space,再交换两个半区。交换以后:
|
|
于是第二次检查问的是一个非常具体的问题:
|
|
累计曾经分配过多少并不重要。压力程序累计分配超过 10 KiB,只要每次 collection 后“活对象加本次请求”仍能放进 4 KiB,就可以继续执行。
哪些情况会 abort
当前固定堆有意不自动增长。以下情况进入 mini_gc_abort():
- 请求大小为 0、不是 8 字节对齐,或单个请求超过 4096 字节;
- collection 后,活对象与本次请求仍放不进一个半区;
- 两块半区或 shadow root area 的宿主
malloc失败; - 运行时发现越界地址、非法 header 或其他破坏 GC 不变量的状态。
GC 的 abort 不等于源语言动态错误。call、函数 eq?、size、get 等已有受检错误仍按第八章约定走 exit(70);堆耗尽属于当前小型 runtime 无法继续工作的宿主级失败,不伪装成状态 70。
固定 4 KiB 不是性能建议,而是教学工具:它让一个很短的示例就能跨越多轮 collection。这里不实现堆增长,也不单独释放某个对象;旧 from-space 中未复制的垃圾会在半区交换时整批失效。