# 背景
在开发较大的 Rust 程序时,有时候需要调用一些 Go 实现的代码;特别是在将 Go 程序用 Rust 重写时,更需要 Rust 和 Go 混编的能力来渐进式重写,相信这对于很多公司来讲都是一个较强的需求。
我从零设计并实现了一个支持 Rust 异步调用 Golang 的框架,欢迎各位使用或一起让它变得更好!
项目开源于 https://github.com/ihciah/rust2go
我写了一篇 blog 详细介绍它的技术细节:[Rust-Golang FFI 框架设计与实现]( https://www.ihcblog.com/rust2go/)
> 我也会在 2024 年 9 月 8 日下午的 RustConfChina2024 上介绍这个项目的设计与实现,欢迎大家关注!
# 核心技术
1. 异步支持:支持异步调用 Go 函数,避免阻塞 Rust 线程。
2. 引用优先的内存布局转换:在可能时优先传递引用,避免内存拷贝;同时支持在传递深层递归结构时最小化内存拷贝。
3. 用户友好的使用体验:借助 Rust 过程宏和代码生成工具,为用户带来简单方便的使用体验。
4. 内存安全:框架内部支持管理参数所有权,避免内存泄漏和悬垂指针。
# 使用姿势
1. 定义调用需要的 struct 和 trait
按 Rust 写法写即可,放置于代码目录内直接使用; struct 支持嵌套自定义结构; trait 参数支持传递引用。
| 定义调用参数和返回值,并添加修饰宏 | 定义调用 trait 并添加修饰宏 |
| --------------- | --------------- |
| ![define structs with macro]( https://i.fshex.co/n3p3w75Zl.png) | ![define traits with macro]( https://i.fshex.co/44Eo4OTZl.png) |
2. 利用 rust2go-cli 生成 Go 代码,并实现生成的 interface
| 生成 Go 代码 | 实现生成的 Go interface |
| --------------- | --------------- |
| ![generate go code]( https://i.fshex.co/9FZB4Sd6l.png) | ![implement go interface]( https://i.fshex.co/h7s8VLT2l.png) |
3. 在项目中添加 `build.rs` 以自动化构建 Golang 并链接
| 添加 build.rs |
| --------------- |
| ![add build.rs]( https://i.fshex.co/3po5Xfe0l.png) |
4. 开始调用
你现在可以直接使用已经定义的 struct 来调用生成的 trait 实现了!
| 使用生成的 TraitImpl |
| --------------- |
| ![use generated impl]( https://i.fshex.co/NRAO8kmal.png) |
你不需要折腾复杂的编译过程,直接 `cargo build` / `cargo run` 即可!不出意外的话,可以预期下面的结果:
> 注:默认是静态链接,可以修改 build.rs 切换为动态链接
![demo result]( https://i.fshex.co/mkqDX2aB.png)
# 问题与难点
通常 Rust 调用其他语言( C/C++)只需要借助 C FFI 接口实现即可,有 `bindgen`, `cbindgen`, `cpp!` 等工具可以快速实现。
但这对 Golang 并不适用,这里的问题在于:
1. 内存布局差异:Go 结构和 C 结构内存布局不同,无法互相理解。
2. 异步系统差异:Go 代码运行在 go runtime 上,其很有可能是异步的,常规 FFI 会占用调用方线程等待,造成调用方 Runtime 卡住或线程池开销。
例如 Go 实现中包含一个 HTTP 请求,那么 Rust 线程会在这个请求完成前一直阻塞,造成性能问题。即便使用 `spawn_blocking` 等手段将其放到线程池中,也会造成极大的资源开销。
3. 生命周期管理:考虑异步的情况下,需要妥善管理参数和返回值的生命周期;同时也需要妥善处理调用方取消调用时的内存安全问题。
例如调用参数传递引用,但在 Golang 执行完毕,调用方已经取消调用 drop Future 并 drop 调用参数,这时候 Go 端还在使用这个参数,就会造成内存安全问题。
另一个问题是,当 Go side 执行结束后,需要将结果返回给 Rust side 。此时该数据一定是 Rust side 负责管理的,那么如何完成变长数据的传递呢?
# 设计与实现
> 本文仅仅简单概述关键问题的解决思路,详细设计请移步 [Rust-Golang FFI 框架设计与实现]( https://www.ihcblog.com/rust2go/)
1. 内存布局问题
我设计了一套过程宏,用于自动生成某个结构体对应的 `Ref` 结构,这个结构是 `repr(C)` 的,用于直接传递其指针给对端。
同时,我也会在 go 代码生成时 parse 这个定义,并生成对应的 CGO 结构体,用于对端理解传递的指针。
当然,原始结构到 Ref 结构的转换也是基于过程宏自动实现的。为了性能,这里的实现较为复杂,区分了多种嵌套类型。例如,对于 `String` 只需要传递指针和长度,但如果要传递 `Vec<String>`,则不得不生成一个中间结构,因为对端并不能理解 `String` 的内存布局(不知道数据的指针和长度要怎么从 `String` 这个结构中读到)。
2. 异步支持
> 如果你对 Rust 异步不够了解,可以参考我的这篇介绍:[Rust Runtime 设计与实现-科普篇]( https://www.ihcblog.com/rust-runtime-design-1/)
基于 CGO 调用,在 Golang 侧将任务 go 出去执行后立刻返回,本质上发起调用可以理解为一次 task dispatch 。
在 Go 函数执行结束后,它需要将结果返回给 Rust 。由于 Golang 函数已经执行完毕,数据的所有权一定是 Rust 侧在维护,但 Rust 侧无法预知 Go 侧返回的数据大小,因此这里使用了一个非常巧妙的设计:在调用时,Rust 侧传递一个 `set_result` 函数指针(该函数由 Rust 侧实现),在 go 执行完毕后,通过 CGO 调用该函数来拷贝返回结果并 wake Future 。
3. 生命周期管理
我设计了一个 AtomicSlot 用于管理参数和返回值的生命周期,这个结构会被双边同时访问,借助原子操作保证并发安全。其管理的内存会在双边都退出后释放,这样保证了 Future drop 时的内存安全。
# 性能优化
考虑到低版本 Golang 的 CGO 性能问题(go 1.21 开始 CGO 性能有较大提升),我还设计并实现了一个共享内存队列来替代 CGO 调用,这是一个无锁队列,一侧读一侧写(类似 virtio ring 的设计)。
这个共享内存队列实现在一个[单独的包]( https://github.com/ihciah/rust2go/tree/master/mem-ring)中,如果有这方面的需求,可以单独引入使用。
经 benchmark 共享内存版本在 Go 1.18 下相比 CGO 版本有最多 20% 的性能提升。
# 未来规划
1. 当前仅支持 Vec 、String 、u8 、usize 等基础类型及其组合,未来需要支持 HashMap 等多种常见类型。
2. 当前请求结构体定义不支持泛型参数,未来需要支持泛型参数(包括 lifetime )。
3. 当前模式下,如需 Go 调用 Rust ,需要手动传递指针并调用,未来需要支持 Go 调用 Rust 的自动生成。
4. 期待各位的建议! |
|