重构 rust2go:一个月,二十个小 PR

Renjie Li · 2026-09-11

本文还有 英文版本

rust2go 是由 @ihciah 创建的 Rust–Go FFI 框架。它让 Rust 可以直接调用 Go (也支持反向调用),原生支持异步,而且不需要序列化:参数通过 C ABI 以内存引用的方式传递, 高频调用还可以走共享内存队列和汇编快路径。设计细节可以看 ihciah 的两篇文章: Design and Implementation of a Rust-Go FFI FrameworkRust2Go Part2: Exploring CGO Calls for Extreme Performance

今年夏天,我开始以核心贡献者之一的身份参与这个项目的维护。过去一个月(2026 年 8 月 24 日 – 9 月 11 日), 我在 Kimi(一个 AI 编程智能体)的协助下,对整个仓库做了一次完整的重构—— 具体工作由它在我的监督下规划和执行:20 个 PR,改动 96 个文件,+6,426 / −4,046 行; 代码覆盖率从零引入,首次测量 93.04%,最终提升到 97.39%——公开 API 零破坏。

本文是一份回顾,但希望不是一份无聊的回顾。我确实会讲做了什么、按什么顺序做——但重构里有趣的部分 从来不是搬代码本身,而是搬代码的过程中暴露出来的东西。所以这篇文章会用相当大的篇幅讲 bug: 一个动用了 valgrind、ASan 和 rr 才定位到的堆损坏,一台挂了好几周才招供的 Mac,一个不是指针的"指针", 以及一个让自己永远活下去的任务。

注:本文是面向维护者的工程记录,不是使用文档。

为什么重构

rust2go 从 2023 年起自然生长至今:代码生成、derive 宏、CLI、build script 助手、两个运行时 (tokio 和 monoio)、两种后端(CGO 和共享内存),外加手写的 Go 汇编。功能越堆越多,结构性债务也越积越多:

这些问题都不会直接伤害用户——但每一个都让下一次变更更危险。所以这个月的目标不是新功能, 而是让项目变得更好改、改起来更安全。

从哪开始:从零搭覆盖率

在这个月之前,项目没有任何覆盖率——没有测量,没有门禁,而且(我们很快就会发现)有些测试从来 没有被真正运行过。所以第一个 PR(#173) 没有重构任何代码,它搭建的是测量本身:

首次测量出来了:93.04%——比我担心的好。但这个 PR 真正的价值不是这个数字, 而是新测试真正跑起来之后发生的事情。

第一个发现有点尴尬:test/go 里的 Go 单元测试从来没被 CI 执行过。一旦跑起来, 所有依赖状态的用例全部失败,报 user not exist——测试构造的是一个 map 为 nil 的空 Demo。它们从写下的那天起就是坏的,而没人知道,因为没人跑过。

第二个发现要黑暗得多。新的 mem-ring 测试直接把测试进程跑崩了——偶发地,glibc 报错 tcache_thread_shutdown(): unaligned tcache chunk detected。堆损坏。 这场排查的经过就是下面的第一个侦探故事;它最终牵出了一个由内核亲自写坏已释放内存的 use-after-free,和一个被关闭了两次的文件描述符。这两个 bug 当然和覆盖率本身毫无关系。 但没有覆盖率,就不会有这些测试;没有这些测试,就没人会知道它们的存在。

第 0 步:先织安全网,再动手术

覆盖率就位后,下一个任务(#176) 依然没有做任何重构——只加了一批把现有行为固定下来的测试:

这张网几天内就回本了:新鲜度检查立刻抓到一份被手工改过、已经和生成器输出不一致的 gen.go; golden test 则在我重构原语类型表时拦住了一个笔误(u8 写成了 uint)。 没有安全网的重构只是在祈祷;有了安全网,之后的每个 PR 都变得平淡无奇——而这正是我们想要的效果。

Phase 1:先修真 bug,再搬代码

在做任何结构调整之前,先把已知的 P0 bug 修掉——小而独立的 PR,好审、好回滚:

Phase 2:结构

bug 修完、测试就位之后,结构调整就可以放心做了:

Phase 3:健壮性

Phase 4:工具链与 CI

四个侦探故事

重构最有价值的产出,是它暴露出来的 bug。按我们遇到它们的顺序,讲四个最精彩的。

1. 内核往已释放的内存里写数据

第一波覆盖率测试跑起来后,mem-ring 的测试套件开始崩溃——不是每次都崩——glibc 报 tcache_thread_shutdown(): unaligned tcache chunk detected。一个只在线程退出时 才现形的堆损坏,发生在一个无锁共享内存队列里,而且是在我无法 SSH 上去的 CI 机器上。本机按设计 没有 Rust 工具链,所以每个假设都只能靠推一个 commit、盯着 CI 来验证。

于是我们把 CI 变成了调试器。前后十几个临时 commit:单线程跑测试;每个测试放独立进程里跑; 按测试逐个排除做二分;上 valgrind;上 gdb 加 malloc 检查;上 AddressSanitizer;循环跑套件统计 崩溃率;甚至用 rr record 加反向 watchpoint 想当场抓住肇事者。中间的发现不断推翻 简单的理论:强制 monoio 用 legacy driver(不用 io_uring)会改变崩溃率但修不好;monoio 的 sync feature 一度看起来像分水岭,最后发现也不是。

真正的答案是两个 bug,不是一个。

第一个:写入者是内核的 use-after-free。monoio 版的 Awaiter::wait 把通知 fd 读进一个 thread_local 缓冲区,以裸指针(RawBuf)的形式交给运行时。 而 spawn 出来的 unstuck_handler 任务可能在 runtime 销毁时泄漏(detached 的 JoinHandle 加上 waker/OpRc 循环引用),留下一个 正在进行中的内核 recv,握着一个指向已释放 TLS 内存的悬垂指针。之后某次 notify() 完成了这次读取,内核就高高兴兴地往分配器早已复用的内存里写了 8 个字节—— 堆元数据当场死亡。修法是让这个 op 读进一个自己拥有的 Vec,它的生命周期和操作本身 一样长,洞就堵上了。

第二个:被关闭两次的 fd。Queue::readrun_handler 过去直接把队列自己的 fd 交给对侧的 Notifier/Awaiter,而 Queue::drop 也会关闭它。一个描述符,关了两次。在安静的进程里这无所谓——但测试是 并行跑的,两次关闭之间,这个 fd 号可能已经被一个完全无关的属主复用,于是人家的描述符 就被凭空关掉了。经典的 fd 复用陷阱,想故意复现都难。

最终的修复不是打补丁,而是一个小重新设计:fd 所有权和内存所有权分开跟踪。fd 被移交Notifier/Awaiter,在队列里标记为 -1,保证恰好关闭一次; Queue::drop 只关闭自己仍持有的 fd,do_drop 只管共享内存的释放,别的不管。 测试通过对端 POLLHUP 来验证"恰好关闭一次"——这样即使 fd 号被复用,测试依然可靠。

总成本:一个 PR,大约二十次 CI 运行,以及对 thread_local 裸缓冲区的一份崭新的敬意。

2. 挂起的 Mac

后来我给 CI 加 macOS arm64 腿时——本来"只是为了覆盖率"——它立刻就挂了: TestCallFuncP0 十分钟超时,而 G0 变体和整个 Linux 全都通过。asmcall 的非 G0 跳板 是在 goroutine 栈上裸 CALL R8 + RET;G0 变体则会额外切栈、对齐 SP、 保存寄存器。由于本地没有 macOS 环境,我先把测试限定在 Linux,macOS 只保留编译/vet 覆盖, 并把当时的首选假设(SP 对齐)记进了已知问题清单。

几周后真正的根因落地了——而且不是对齐。在 arm64 上,CALL(即 BL)会把 返回地址写进链接寄存器 x30——而跳板根本没保存它。只要 C 被调函数自己再调用别的函数, x30 就会被踩掉,于是跳板最后的 RET 跳回了它自己,永远空转。在我的 MacBook 上,lldb 一眼就证实了:pc == lr == CallFuncP0+8。补一对保存/恢复 LR 的指令 就修好了——新加的 linux/arm64 CI 腿现在守着它,因为这个 bug 从来不是 darwin 特有的:linux/arm64 会一模一样地挂,只是从来没被覆盖到。一条"只是为了覆盖率"顺手加的 CI 腿,最终挖出了一个早于这次 重构就存在的 ISA 级潜在 bug。

3. GC 逃逸

新的 mem-ring 停止测试有两次 CI 运行出现了内存损坏。原因出在我写的测试辅助代码里:它把一个局部变量 经由 uintptr 塞进 QueueMeta,脱离了 Go GC 的保活追踪;栈帧被复用后, 这个"指针"就悄悄指向了别人的数据。修法是把状态放到堆上、存真正的指针。教训重温:uintptr 不是指针,GC 没有义务替你保活它指向的东西。

4. 循环保活

在提升覆盖率的过程中,我发现 mem-ring 的 unstuck_handler 有一个退出分支不可达——不是没测到, 而是死代码。handler 任务自己持有共享 inner 状态的 Arc,而 inner 状态里又存着停止通道的 Receiver。只要 handler 活着,receiver 就永远不会被释放,tx.closed() 永远不会触发—— 一个循环保活,导致这个任务一直泄漏到 runtime 退出。修法是把所有权倒过来:停止 guard 只在用户侧克隆之间共享 (手动实现 Clone、跳过 guard),handler 改为显式接收依赖参数,而不再持有整个世界。 回归测试现在会 drop 掉所有 WriteQueue,并断言挂起的条目不再被刷出。

覆盖率:为什么停在 97.4%

集中的测试攻坚战(#191)之后,覆盖率到了 97.39%。剩下的约 24 行是真正不可测或纯防御性的:proc-macro 入口在 rustc 内部执行,llvm-cov 无法插桩;不可能状态的 panic 分支;以及极窄的竞争窗口。我选择把门禁设在 97%(阈值 0.5%,且故意不设 patch 级门禁,这样增加防御性代码永远不会被卡住),而不是为了凑个整数去写表演性质的测试——靠刷数字 凑出来的覆盖率,远不如一个如实说明原因的覆盖率有意义。

过程记录

数字与致谢

感谢 @ihciah 创建了 rust2go,感谢他的信任和 review—— 也感谢 Kimi 和我一起完成了这些重活。 如果你在从 Rust 调 Go——或者只是喜欢 FFI 兔子洞——欢迎看看 这个项目


← 返回首页