重构 rust2go:一个月,二十个小 PR
本文还有 英文版本。
rust2go 是由 @ihciah 创建的 Rust–Go FFI 框架。它让 Rust 可以直接调用 Go (也支持反向调用),原生支持异步,而且不需要序列化:参数通过 C ABI 以内存引用的方式传递, 高频调用还可以走共享内存队列和汇编快路径。设计细节可以看 ihciah 的两篇文章: Design and Implementation of a Rust-Go FFI Framework 和 Rust2Go 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 汇编。功能越堆越多,结构性债务也越积越多:
- Go 代码生成器挤在一个 1300 行的文件里,同一份原语类型映射散落在五个不同的
match块中。 - derive 宏路径和 CLI 路径各有一份 ref struct 展开逻辑——两条并行的代码生成路径,随时可能悄悄走向不一致。
- 五个示例项目里有逐字节相同的
build.rs和复制粘贴的 Go 实现。 - 宏报错用的是
panic!/unwrap(),用户看到的是编译器崩溃,而不是一条有用的诊断信息。 - 文档已经和代码脱节;没有覆盖率门禁——事实上,连覆盖率测量本身都不存在。
这些问题都不会直接伤害用户——但每一个都让下一次变更更危险。所以这个月的目标不是新功能, 而是让项目变得更好改、改起来更安全。
从哪开始:从零搭覆盖率
在这个月之前,项目没有任何覆盖率——没有测量,没有门禁,而且(我们很快就会发现)有些测试从来 没有被真正运行过。所以第一个 PR(#173) 没有重构任何代码,它搭建的是测量本身:
- 一个 CI 覆盖率 job:Rust 侧用
cargo llvm-cov收集(lcov 格式),Go 侧收集test/go模块的覆盖率,两份报告都上传 Codecov。这个 job 必须关掉 sccache——它和覆盖率插桩合不来。 - 第一波单元测试,瞄准从未被测过的核心:
rust2go-convert(原语、String、Vec、Option、元组的 ToRef/FromRef 往返)、rust2go::slot的原子 slot 状态机、ResponseFuture的 poll 生命周期,以及 mem-ring 的队列/eventfd 内部实现。
首次测量出来了: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) 依然没有做任何重构——只加了一批把现有行为固定下来的测试:
- Go 代码生成器的 golden test:生成的 Go 代码与入库的快照逐字节比对,任何不一致都会立刻暴露。
rust2go-mem-ffi(payload flag、slab 辅助函数、slot 状态机)和 Go 侧(mem-ring 的队列/slab、asmcall 跳板)的单元测试。- CI 新鲜度检查:从绑定文件重新生成
gen.go,只要和仓库里提交的版本不一致就报错。
这张网几天内就回本了:新鲜度检查立刻抓到一份被手工改过、已经和生成器输出不一致的 gen.go;
golden test 则在我重构原语类型表时拦住了一个笔误(u8 写成了 uint)。
没有安全网的重构只是在祈祷;有了安全网,之后的每个 PR 都变得平淡无奇——而这正是我们想要的效果。
Phase 1:先修真 bug,再搬代码
在做任何结构调整之前,先把已知的 P0 bug 修掉——小而独立的 PR,好审、好回滚:
- mem-ring 写 goroutine 的自死锁(
continue之前没有Unlock)。 - Go slab 分配器空闲链表的哨兵值错误。
- 测试套件里一个误写的
#[mem_call]属性(应为#[mem])——被新的新鲜度检查抓到。 - amd64/arm64 汇编里的参数名错误(
arg1+0x18(FP)应为arg2+0x18(FP))——碰巧机器码完全一样,但源码在撒谎。 monoio和tokiofeature 本应互斥,但没有任何机制来保证;更糟的是 CI 一直用--all-features构建,编译的是一个用户根本构建不出来的配置。现在用compile_error!守卫拒绝这种组合,CI 也把两个运行时拆成独立的 job。
Phase 2:结构
bug 修完、测试就位之后,结构调整就可以放心做了:
- 原语类型的单一事实来源。一张
(rust_ident, c_name, go_name, ...)表取代了五处散落的match;ref struct 分类逻辑也由 derive 宏和 CLI 共享——两条 codegen 路径从此不可能再走向不一致。 - 新的
rust2go-gencrate。generate()从 CLI 移进库 crate,只接受一个纯数据的GenArgs。CLI 变成薄壳,rust2gocrate 的公开 API 也不再泄漏clap/cbindgen。 - 分层 codegen。1300 行的
common.rs按ir/emit-rust/emit-go/emit-c分层(r2g、g2r 两个方向都如此),内嵌的 Go 模板字符串变成独立的.go.tmpl文件,用include_str!引入。golden test 证明这次拆分是字节级精确的:生成输出一个字节都没变。 - 共享示例模板。五份逐字节相同的
build.rs、四份相同的impl.go和重复的教程文字收拢进examples/shared/,CI 新增同步检查,保证四份impl.go拷贝一致。每个示例现在只展示它真正不同的部分:运行时 × 后端。
Phase 3:健壮性
- 宏应该报告错误,而不是崩溃。宏路径上所有
panic!/unwrap()都换成了带 span 的syn::Error。写错绑定文件时,用户现在会看到一条规范的编译错误,直接指向出错的 token——同时补了回归测试,包括queue_size溢出的情况。(顺手还修了unreconigzed拼写错误,和一族叫trat的变量名。) - mem-ring 能干净地停下来了。
Notifier.Notify、Awaiter.Wait、NewAwaiter现在返回 error,不再对着已关闭的 fd 死循环;写 goroutine 和RunHandler有了真正的停止机制,替换掉了原来那套形同虚设的旧机制;NewAwaiter显式接管 fd 所有权,finalizer 再也不会在连接还活着的时候把描述符关掉。这个包也明确声明了//go:build unix约束,而不是在 Windows 上莫名其妙地编译失败。
Phase 4:工具链与 CI
- 去掉了
cgocall和asmcall之间重复的 cgo fallback,并把 workspace 依赖集中管理。这暴露了一个真正的 Cargo 坑:对于 workspace 继承的依赖,成员 crate 里写的default-features = false会被静默忽略——必须在 workspace 根部声明。这个疏忽导致一个默认 feature 被悄悄开启,进而触发了我们自己加的 monoio/tokio 互斥守卫。 - CI 现在钉住 Go 工具链(用
stable别名跟随新版,外加钉死的 Go 1.18 下限——1.18 支持是刻意的兼容性承诺,现在 CI 真正测它了),门禁加上gofmt -l和go vet,Go 测试套跑在 linux amd64 和 arm64、macOS arm64、Windows amd64 四条腿上。 - 覆盖率从最初的 93.04% 提升到 97.39%(未覆盖行 249 → 102),Codecov 门禁设为 97%——为什么是这个数字,见下文。
四个侦探故事
重构最有价值的产出,是它暴露出来的 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/Op 的 Rc 循环引用),留下一个
正在进行中的内核 recv,握着一个指向已释放 TLS 内存的悬垂指针。之后某次
notify() 完成了这次读取,内核就高高兴兴地往分配器早已复用的内存里写了 8 个字节——
堆元数据当场死亡。修法是让这个 op 读进一个自己拥有的 Vec,它的生命周期和操作本身
一样长,洞就堵上了。
第二个:被关闭两次的 fd。Queue::read 和 run_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 级门禁,这样增加防御性代码永远不会被卡住),而不是为了凑个整数去写表演性质的测试——靠刷数字
凑出来的覆盖率,远不如一个如实说明原因的覆盖率有意义。
过程记录
- 每个工作日一个小 PR。每个任务都从最新 master 新切分支开发,当天 squash 合并。小 diff 才好审;二十个小 PR 远比一个一万行的大炸弹值得信任。
- 每次推送前做独立审查。每个改动都经过迭代式的独立 code review 循环,修到没有待处理的问题为止。
- 文档与代码同 PR,不维护 CHANGELOG。用户可见的变更直接融入 README 和
docs/正文,和代码在同一个提交里,保证文档永远描述 HEAD 的代码状态。其中一个 PR 是对全历史的文档审查,把自第一个外部贡献以来的所有用户可见变更都梳理核对了一遍。 - CI 是唯一的验证者。我的机器按设计没有 Rust/Go 工具链,每个 PR 都纯靠 CI 验证。好几个 PR 首轮是红的——每个失败都做了根因分析(而不是重试祈祷),当天修复,当天绿着合并。
- AI 辅助执行。日常的具体工作——规划、写码、review-修复循环、CI 日志分析、合并——都由 Kimi(一个运行在我机器上的 AI 编程智能体)按"每个工作日一个任务"的节奏完成。我负责定目标、做判断(比如把 Go 1.18 支持作为刻意的兼容性承诺保留下来,以及拒绝为覆盖率凑整数),并审查结果。本文也是在 Kimi 的协助下起草的。
数字与致谢
- 合并 20 个 PR(#173 – #193),改动 96 个文件,+6,426 / −4,046 行。
- 代码覆盖率从零引入:首次测量 93.04%,最终 97.39%,门禁 97%。
- 公开 API 零破坏;codegen 拆分前后生成的 Go 输出逐字节一致。
- 顺手挖到并修复了四个和重构本身无关的潜在 bug——包括 mem-ring 的堆损坏和汇编跳板里的 arm64 ABI bug。
感谢 @ihciah 创建了 rust2go,感谢他的信任和 review—— 也感谢 Kimi 和我一起完成了这些重活。 如果你在从 Rust 调 Go——或者只是喜欢 FFI 兔子洞——欢迎看看 这个项目。