给 Rust 来点 Go – 高性能异步 FFI 框架 Rust2Go

1次阅读

共计 2310 个字符,预计需要花费 6 分钟才能阅读完成。

背景

在开发较大的 Rust 程序时,有时候需要调用一些 Go 实现的代码;特别是在将 Go 程序用 Rust 重写时,更需要 Rust 和 Go 混编的能力来渐进式重写,相信这对于很多公司来讲都是一个较强的需求。

我从零设计并实现了一个支持 Rust 异步调用 Golang 的框架,欢迎各位使用或一起让它变得更好!

项目开源于 https://github.com/ihciah/rust2go

我写了一篇 blog 详细介绍它的技术细节:Rust-Golang FFI 框架设计与实现

我也会在 2024 年 9 月 8 日下午的 RustConfChina2024 上介绍这个项目的设计与实现,欢迎大家关注!

核心技术

  1. 异步支持:支持异步调用 Go 函数,避免阻塞 Rust 线程。
  2. 引用优先的内存布局转换:在可能时优先传递引用,避免内存拷贝;同时支持在传递深层递归结构时最小化内存拷贝。
  3. 用户友好的使用体验:借助 Rust 过程宏和代码生成工具,为用户带来简单方便的使用体验。
  4. 内存安全:框架内部支持管理参数所有权,避免内存泄漏和悬垂指针。

使用姿势

  1. 定义调用需要的 struct 和 trait

    按 Rust 写法写即可,放置于代码目录内直接使用;struct 支持嵌套自定义结构;trait 参数支持传递引用。

    定义调用参数和返回值,并添加修饰宏 定义调用 trait 并添加修饰宏
    给 Rust 来点 Go - 高性能异步 FFI 框架 Rust2Go 给 Rust 来点 Go - 高性能异步 FFI 框架 Rust2Go
  2. 利用 rust2go-cli 生成 Go 代码,并实现生成的 interface

    生成 Go 代码 实现生成的 Go interface
    给 Rust 来点 Go - 高性能异步 FFI 框架 Rust2Go 给 Rust 来点 Go - 高性能异步 FFI 框架 Rust2Go
  3. 在项目中添加 build.rs 以自动化构建 Golang 并链接

    添加 build.rs
    给 Rust 来点 Go - 高性能异步 FFI 框架 Rust2Go
  4. 开始调用

    你现在可以直接使用已经定义的 struct 来调用生成的 trait 实现了!

    使用生成的 TraitImpl
    给 Rust 来点 Go - 高性能异步 FFI 框架 Rust2Go

    你不需要折腾复杂的编译过程,直接 cargo build / cargo run 即可!不出意外的话,可以预期下面的结果:

    注:默认是静态链接,可以修改 build.rs 切换为动态链接

    给 Rust 来点 Go - 高性能异步 FFI 框架 Rust2Go

问题与难点

通常 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 框架设计与实现

  1. 内存布局问题

    我设计了一套过程宏,用于自动生成某个结构体对应的 Ref 结构,这个结构是 repr(C) 的,用于直接传递其指针给对端。

    同时,我也会在 go 代码生成时 parse 这个定义,并生成对应的 CGO 结构体,用于对端理解传递的指针。

    当然,原始结构到 Ref 结构的转换也是基于过程宏自动实现的。为了性能,这里的实现较为复杂,区分了多种嵌套类型。例如,对于 String 只需要传递指针和长度,但如果要传递 Vec,则不得不生成一个中间结构,因为对端并不能理解 String 的内存布局(不知道数据的指针和长度要怎么从 String 这个结构中读到)。

  2. 异步支持

    如果你对 Rust 异步不够了解,可以参考我的这篇介绍:Rust Runtime 设计与实现 - 科普篇

    基于 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 的设计)。

这个共享内存队列实现在一个 单独的包 中,如果有这方面的需求,可以单独引入使用。

经 benchmark 共享内存版本在 Go 1.18 下相比 CGO 版本有最多 20% 的性能提升。

未来规划

  1. 当前仅支持 Vec、String、u8、usize 等基础类型及其组合,未来需要支持 HashMap 等多种常见类型。
  2. 当前请求结构体定义不支持泛型参数,未来需要支持泛型参数(包括 lifetime)。
  3. 当前模式下,如需 Go 调用 Rust,需要手动传递指针并调用,未来需要支持 Go 调用 Rust 的自动生成。
  4. 期待各位的建议!
正文完
 0