tinyactor 是如何处理 GC 与 C API 交互的

2026-09-25

之前写过一篇tiny-actor,一个参考 Erlang/Gleam 的小的 actor 语言。 最近为了它的 GC 和 C 模块交互的设计,前前后后跟 AI 讨论了很多轮,最后收敛到一个我自己觉得相当漂亮的设计。 感觉值得记录一下。

背景

tinyactor 是 per-process 堆 + 半区复制 GC。每个 actor 一个自己的小堆,GC 时把存活对象拷到另一半。

这里多解释两句。VM 里所有的值都分配在 VM 自己管理的内存上,下文叫它托管内存,由 GC 负责回收。GC 的工作方式是,从一些起始位置出发,能摸到的对象当作活的,摸不到的回收——这些起始位置就是 GC root。 半区复制是把堆分成两半,分配只在其中一半进行,GC 的时候把存活对象拷到另一半,然后两半对调。堆就这么大,一边满了,另一半就是全部的余量,上限就是 2 倍。满了只能 GC,没有"再找操作系统要一点"的选项,这个点后面还会用到。

C 模块(net/file/str 这些)通过一个统一的入口进 VM。在 C 和托管内存怎么交互这件事上,要把设计做对了很难。

先举例子,假设一个 C 模块:

Val a = gcAlloc()   (1)
Val b = gcAlloc()   (2)
// use a           (3) 已经是悬挂引用了

在第 (2) 步中,如果分配 b 的过程中触发了 GC,a 是属于 C 栈上的指针,C 语言栈并不属于 GC root, 于是没有从 GC root 到 a 引用对象的可达路径,于是 a 指向的那块托管内存,会被 GC 回收掉。a 成了一个悬挂引用。 到第 (3) 步,再使用 a 就会出问题了。

其它语言是如何处理的

有些脚本语言虚拟机,提供了一些 API,要求写 C 扩展的人先声明要用到的变量。 大概相当于,弄一个临时的 root 区域,把 a 和 b 都放到这个 root 上面,以保护它在被使用期间,不被 GC 掉。

写 C 模块的时候,都得知道有这样的坑, 比如说 chibi scheme 里面,它就会提供这样的 API:

  sexp_gc_var2(a, b);   (1)
  sexp_gc_preserve2(ctx, a, b);  (2)
  ...
  sexp_gc_release2(ctx);   (3)

第 (1) 步先声明,其实就相当于在宿主语言那边开辟临时 root 区,C 模块里面的两个变量会需要放过去。 第 (2) 步再给它保护起来,a 和 b 现在都是 GC root 可达的了,不会被 GC,所以再使用就安全了。 第 (3) 步,是最后使用结束,需要再把 a 和 b 从 GC root 中清理掉。

问题是这样子,特别不友好。心智负担太重了,比 malloc 需要 free 都有过之而无不及。

因为它还不仅仅涉及,要声明和释放,还有另外的问题。 比如说,我在 C 里面写一个东西

struct My {
    Val x;
};

// a 是 VM 里面的对象传过来的

struct My my;
my.x = a;          (1)
Val b = gcAlloc()  (2)
// use my          (3)

my 是我在 C 语言里面定义的结构体,它引用托管内存上的对象,但是这种情况又是不受 GC root 保护的。 就有可能第 (2) 步里,分配 b 的时候,触发了 GC,于是 a 被回收掉了,这里 my.x 这个引用又挂掉了。

所以 C 栈就不能引用虚拟机托管内存里面的对象。给用户(而不是 GC 实现者)来解释这些规则,是很难解释的。 心智负担实在太重了...即使作者自己也没法保证永远不犯错。

JavaScript 的 V8 也是没有跳出这套路子,它在两个方面让犯错不那么容易。 一个是用 RAII 来保证释放:不用像 chibi 那样显式 release,只要声明一个 scope 对象,出了作用域,这个范围内拿到的 handle 自动全部释放。 另一个是,规则约束,只能拿到 VM 里面对象的引用,解引用都是在确定的作用域里面的。

比如

language-cpp<@>void Foo(const FunctionCallbackInfo<Value>& args) {
  Isolate* isolate = args.GetIsolate();
  HandleScope scope(isolate);   // RAII,出了这个作用域,里面拿到的 handle 全部失效

  Local<String> s = String::NewFromUtf8Literal(isolate, "hello");
  // s 不是对象的直接指针,只是一个 handle
}

Local、Handle 都是受 HandleScope 统一管理的间接句柄,scope 一走,全部作废。

安全性上面有一些加强吧,但是我不觉得这是一个优雅的方案。 尤其是 tinyactor 是 C 语言写的,不像 v8 是 C++,不太好用上这些安全上的处理机制。

Lua 是极少数把这件事情做对的语言

lua 是极少数把这件事情做对的语言,这个观点我提过好多次了。

它用的栈交互协议,根本就不让 C 栈可以直接持有到托管内存中的对象。 它的交互的 API 大概就是 push / pop / dup / swap 这些。通过 C 写扩展的人,就是间接地操作栈上的对象,通过这样的栈交互 API。

C 里面完全拿不到 Lua 对象的指针,全部走间接的引用。 一个 lua 的 C 扩展长这样:

language-c<@>static int l_first(lua_State *L) {
    luaL_checktype(L, 1, LUA_TTABLE);    // 从栈上取第一个参数,它是个 table
    lua_pushinteger(L, 1);               // 把索引 1 压到栈上
    lua_gettable(L, 1);                  // 取 table[1],结果压到栈顶
    return 1;                            // 告诉 Lua 返回值就在栈顶
}

全程没有一个 Lua 对象的指针出现在 C 里面,操作的都是栈上的索引。

那 tinyactor 为什么不直接学 lua 呢? 因为学 lua 就意味着,所有 C 扩展都要用这套 push/pop 的 API 重写一遍,写扩展的人操作的全是间接的栈,啰嗦。我想要的还是自然的 C 函数签名,模块代码像普通 C 代码一样写。

tinyactor 是怎么做的

既要自然的 C 函数签名,又不能有 GC 的心智负担,怎么办? 一开始我偷懒,走的还是第二节批判的那条路:类似 chibi scheme 那种,临时追加 root 来保护 C 的对象。结果果然踩坑,不少 bug 都跟这里的心智负担过高相关,而 GC 的 bug 又不好调。

在我做 cora 语言的时候,其实想过好几个版本的思路。

其中一个版本的做法是,cora 反正是编译到 C 的嘛,所以 C 的栈也可以当作 GC root——GC 回收的时候,把 C 栈的内存也扫一遍,看起来引用着托管对象的,就当作是活的。 这样 C 代码里面就不需要任何保护变量的 API 了。

那我能不能把这个思路借鉴到 tinyactor 这边呢? 进入 C 模块之前,记录下 C 栈的位置,等到了 gcAlloc 里面,再记录下 gcAlloc 的栈位置,把两次之间的这段栈内存区域,作为 GC root 来处理。

这个想法是有坑的。 问题出在 tinyactor 里面我做的是 moving GC,就是前面说的半区复制,对象会被搬动:A 指向 B,B 被搬走了,就需要更新 A,让它指向 B 的新位置。 而扫 C 栈这种做法,用的是非精确 GC(conservative)——扫描的时候拿到一个个机器字,猜它是不是指针。 它没办法严格区分,这个值真的是一个指向托管内存的指针,还是恰好长得像指针的大整数。 假如它只是一个大整数,B 搬家之后,GC 还是会把这个"指针"更新成新地址——一个无辜的 C 栈变量就被改坏了。 所以这条思路走不通过。

另一个版本,也是 cora 那边最终的做法,是让 gcAlloc 的时候不触发 GC,只标记一下"需要 GC",真正的 GC 挪到别处执行。这样 C 模块写起来就没什么心智负担。 cora 编译的时候做了 cps 变换,把代码切成一个个小片段,运行就发生在一个叫 trampoline 的大循环里面:不停地取下一段代码来执行。因为每段都很短,很快就会回到这个循环,在这里检查 GC 标记,有就执行 GC。 很关键的一个点,回到 trampoline 的时候,一定是从 C 模块退出来之后,此刻 C 的栈不增长,没有新的 C 栈对象需要保护。程序里所有对象的状态对 GC 都清晰可见、可以安全停下来做 GC 的位置,就叫 safepoint。这里正好就是一个 safepoint。

这个想法在 tinyactor 这边也不可行。 gcAlloc 只标记不干活的话,堆很快就分配满了,后续的分配没有地方放。cora 那边是全局托管的 GC,内存从一个大池子里分配,池子满了就找操作系统再要一点,问题不大。 但是 tinyactor 这边是 per-process 堆,满了就必须触发 GC,而此刻正在 C 模块里面,既没有空间分配,又不能扩容。堆满了,就只能死,只能 panic。

那就顺着这个思路再往下想一步:既然 safepoint 这条路卡在"堆满必须 GC",那让 C 执行期间干脆不碰托管内存呢? 进入 C 模块之后,C 里面的分配走一块独立的区域,等到退出的时候,把返回值深拷贝回托管内存。 这样写 C 模块的人,就完全不用操心 GC 的事情了。

方向大概是这样。但是这块"独立的区域"是什么,从哪里来,会不会被搬家,都还是问题。

最终方案

落地的时候,我重新看了一眼那句"堆满了,就只能死"。 这句话里藏着一个没有被质疑的假设——堆满了,除了 GC 就没有别的出路了吗?

有。malloc 又没有"满"这回事。而且上一节剩下的三个问题——独立区域是什么、从哪里来、会不会搬家——答案也都在这个词里面。

于是有了最终的方案。进入 C 模块的时候,给一小块初始区域用于分配;这块区域很小,大多数调用根本用不完它。超了之后,后续的分配就落到 malloc 的 chunk 上面,chunk 之间链起来。bump 分配——分配只是移动一下指针——chunk 一旦分出来,终身不搬家。 传进来的参数不用动,托管内存冻结着,地址本来就是稳定的。C 里面新建的值落在初始区域和 chunk 上面,地址也是稳定的。 退出的时候,把返回值深拷贝回托管内存——此刻 GC 也好,堆不够了扩容也好,都随意了——然后初始区域连同整条 chunk 链,一并释放。

用伪代码把一次调用串起来:

C_foo(args) {
    // 进入:初始区域就绪,chunk 链为空
    a = args[0]       // 指向托管内存的对象,冻结,稳定
    b = gcAlloc()     // 不会触发 GC:先落初始区域,超了落 chunk,都是稳定地址
    ...
    // 退出:返回值深拷回托管内存,整条 chunk 链 free
}

最妙的是安全性。C 执行期间,托管内存是冻结的,零分配——新东西全在初始区域和 chunk 上,而这些都是 malloc 出来的,终身不搬家。 托管内存是冻结的旧世界,chunk 是全新的世界。而且 tinyactor 是纯函数语言,值都是不可变的,C 模块没有"修改一个已有对象"的操作,托管这边天然不会产生指向 chunk 的引用。 整链 free 的时候,不可能有活着的指针还指在上面。泄漏和悬垂,都被结构挡住了,根本不需要逐对象去追踪。

写 C 模块的人心智负担是零,没有什么限制。 退出时深拷贝的成本也可接受,绝大多数时候返回值就是一个简单的值,除非是很复杂的链表或者图结构,但这种情况几乎不发生。

tinyactorGC