章节目录

分配器:在两个半区之间轮换

本节阅读量:

对象图告诉我们“什么必须保留”,但正常执行时最常见的动作仍然是创建新对象。若每次分配都立刻遍历整张对象图,程序会慢得难以理解。

第九章把工作分成两种路径:

1
2
还有空间    bump allocation,只移动 free pointer
空间不足    collection,复制活对象后再重试

大多数分配走第一条短路径;只有当前半区放不下新对象时才启动 GC。

两个各 4 KiB 的 semispace

src/runtime/gc.cpp 把 collector 的状态保存在 C++ runtime 内部。mini_gc_init 启动时向宿主 malloc 请求两块内存:

1
2
3
4
5
6
7
from_start = allocate_bytes(kSemispaceBytes);
from_free = from_start;
from_end = from_start + kSemispaceBytes;

to_start = allocate_bytes(kSemispaceBytes);
to_free = to_start;
to_end = to_start + kSemispaceBytes;

kSemispaceBytes 是 4096。allocate_bytes 调用 std::malloc,并检查返回值与 8 字节对齐;失败时统一进入 mini_gc_abort()。这些细节由编译 gc.cpp 的 C++ 工具链处理,不再由汇编 emitter 分别拼出 Linux 的 malloc 和 macOS 的 _malloc。

两块区域各有一个名字:

1
2
from-space    当前创建对象、保存活对象的半区
to-space      下一次 collection 的复制目标

它们各 4096 字节,总共向宿主申请 8192 字节对象空间,但任一时刻可用的活动容量仍只有一个半区的 4096 字节。copying collector 用一半空间作为下一次搬家的目的地,这是换取简单算法的固定成本。

此外,运行时还会单独申请 shadow root stack;它不占这 8192 字节,下一节再讲。

bump allocation 只做一次加法

from-space 中有三个关键地址:

1
2
3
4
5
6
from_start                 from_free                 from_end
    |                          |                         |
    v                          v                         v
    +--------------------------+-------------------------+
    | 已经分配的对象           | 尚未使用                |
    +--------------------------+-------------------------+

申请 size 字节时,快速路径只需:

1
2
3
4
5
6
result = from_free
next = from_free + size

if next <= from_end:
    from_free = next
    return result

这里返回的是对象起始处的原始地址。所有对象大小都是 8 的倍数,两个半区的起点也满足对齐,因此 bump pointer 每次移动后仍然对齐,低三位可以安全用于 Value tag。

分配器不会替具体对象写 header。它只切出一段足够大的空白空间;调用者随后写入 record、closure 或 cell 的布局。

mini_alloc(size, root_top) 的 C ABI 约定

生成的汇编与 C++ runtime 需要互相调用,却不能依赖 C++ 编译器修饰后的函数名。gc.h 因此把边界声明为 extern "C":

1
2
3
extern "C" std::uintptr_t* mini_alloc(
    std::size_t bytes,
    std::uintptr_t* root_top) noexcept;

extern "C" 固定了链接名称;它没有把实现语言改成 C。函数体仍然位于 gc.cpp,可以使用 C++ 的类型、名字空间和辅助函数。

生成代码通过固定寄存器调用分配器:

register 调用前 返回后
%rdi 请求的字节数 无约定
%rsi shadow root stack 的第一个空地址 无约定
%rax 无约定 from-space 中未加 tag 的原始地址

也就是:

1
mini_alloc(size, root_top) -> new_raw_address

root_top 让分配器在慢路径中知道需要扫描多少 root。即使大多数调用不触发 collection,也必须每次都传,因为调用方不能预先知道剩余空间是否足够。

压力程序创建八字段 record 时,当前 emitter 生成下面这些关键行;中间七个同形的字段写入在这里省略:

1
2
3
4
5
6
7
8
9
movq $72, %rdi
movq %r15, %rsi
call mini_alloc
movq $2049, 0(%rax)
movq -56(%r15), %rcx
movq %rcx, 8(%rax)
# 其余七个字段同样从 root slot 取回
orq $4, %rax
movq %rax, -120(%r15)

72 是 header 加八个字段的总大小。2049 来自:

1
(8 payload words << 8) | record kind 1 = 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++:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
if (!has_room(from_free, bytes, from_end)) {
    collect(root_top);
}
if (!has_room(from_free, bytes, from_end)) {
    fail();
}

Byte* result = from_free;
from_free += bytes;
return reinterpret_cast<Word*>(result);

collection 会把仍然可达的对象复制到原来的 to-space,再交换两个半区。交换以后:

1
2
new from_start = old to_start
new from_free  = copied live objects 的末尾

于是第二次检查问的是一个非常具体的问题:

1
live_bytes + requested_bytes <= 4096 ?

累计曾经分配过多少并不重要。压力程序累计分配超过 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 中未复制的垃圾会在半区交换时整批失效。


9.1 对象图:从“还拿得到”定义活对象

上一节

9.3 roots:用独立栈保存 GC 的起点

下一节