特别提示: 该文长 26000+ 字, 阅读时间可能需要超过 60 分钟。 建义先保存收藏观看。 本次分享只是 Rust FFI 中的一部分,其中目录中标灰的内容还没讲到,还会有后续的更新。 可以关注 Databend 公众号了解最新动态。

Rust 培养提高计划回放 : http://t.cn/A6M4JIOx

分享背景:

在我所加入的各种 Rust 讨论群中,大家经常问的一个直击灵魂的问题就是:你们在生产环境中用Rust了吗?

的确,现在掌握 Rust 的人还相对较少,很多学习 Rust 的同学可能是出于自己的兴趣爱好,实际并无法在工作中有机会实际使用。我本人也是在两年前接触的 Rust,中间一直断断续续地学习,但大部分停留在看书、看教程、看文档、看别人吐槽的阶段,自己写过的代码也都是不超过几百行的小 Demo。虽然后来在 B 站做过一系列《Rust过程宏实战系列教程》,那也是自己学习Rust的副产品,纸上谈兵,和实际工作没有任何关系。直到三个月前,我才下定决心要在生产环境中实战一把。

本次分享希望可以给大家提供一个思路,通过替换系统中最小模块的方式,逐步用 Rust 来“蚕食”现有系统,实现逐步替换,并逐步积累在生产环境中使用 Rust 的经验。

通过本次分享,我希望可以:

  • 减弱大家在生产环境中引入 Rust 的恐惧感,如果你还在犹豫、观望,不妨 get your hands dirty first , 然后大家就可以放开了去搞了。
  • 了解Rust为什么比一些常用的语言(如 Golang、Java 等)更适合开发通用二进制库
  • 了解一下 Rust 开发 C-FFI 库的方法,并介绍一些个人实际使用中遇到的问题(也可能是我太菜)
  • 了解一下 Golang 中 Cgo 的使用
  • 有很多朋友在纠结 Golang 和 Rust 选哪个,通过本次分享,加深对两个语言的了解,为大家做选择多一些参考

分享目录

  • Get hands dirty
  • Rust 在开发二进制库上的优势
  • 为什么选择 Golang 作为调用示例
  • 配置一个 Rust 项目,使之能够编译出满足 C-FFI 的动态链接库
  • 开发一个 Go 项目,调用 Rust 编写的库
  • Case By Case,介绍常见的应用场景
  • 字符串的传递
  • 结构体以及函数方法
  • 回调函数的使用
  • FFI 接口处的并发安全问题
  • 错误处理
  • 性能测试:用 Rust 重写 Go 模块,真的会更快吗?

目标听众

  • 对 C 语言有大致了解,大学 C 语言基础就够了
  • 熟悉 Go、Python、Java这类具有 GC 的语言,但对 C 这样需要手工管理内存的语言不熟悉的同学
  • 了解 Rust,并希望将Rust与其他语言配合使用的同学
  • 熟悉 Go 语言,但不了解 cgo 使用方法的同学
  • 如果你已经熟练掌握的 Rust、C/C++、Go,那么这个分享可能过于简单,建议直接 Clone Databend 的代码,开始为Databend贡献代码~逃~~

Rust 在开发二进制库上的优势

我们知道,如果两个不同的编程语言希望互相调用对方编写的函数,那么两种语言必须达成很多共识,包括但不限于:

  • 各种数据结构在内存中是如何布局存储的
  • 函数调用时,参数如何传递,返回值如何传递(例如,是用寄存器来传递,还是用栈来传递?)
  • 去哪里申请内存空间(使用的内存分配器是什么?),申请使用的内存空间,何时释放,由谁来释放(有没有GC?)?
NN * (N-1)ABIFFI

那么我们来看下一个问题,大家都来兼容C语言定义的标准,事情就这么简单吗?显然不是,毕竟 C 语言已经很老了,如果各种新生语言都拘泥于 C 语言的规范,那怎么能有创新呢?

于是就出现了这样一种局面:由于不同的编程语言在具体实现上千差万别,有各自的特性,大家只在基础的功能上兼容 C 语言,而各种高级特性往往就不能直接满足 C 语言的标准。因此不同的语言对 C 语言所定制的函数调用规范的支持程度也不一样,这就造成了有一些语言之间互相调用很容易,开销也很低,而另外一些语言之间的相互调用会很困难,或者说效率很低。(大家可以回想一下那句名言:没有什么问题是加一层中间件解决不了的,如果有,那就加两层。如果两种编程语言在设计上差异过大,那么势必要在相互调用时进行一些中间的转换工作,而其代价就是互相调用的效率变低。)

我们常见的开发语言,通常可以分为两大类:

  • A类:是没有虚拟机、运行时,需要开发者自己管理内存的语言,例如 C、C++、Rust 等。
  • B类:是具有虚拟机、具有运行时等协助管理内存的组件的语言,例如 Java、Golang、Python、PHP 等。

通常,上述A类语言,都能够完美简洁的支持C语言的调用规范,而 B 类中的语言,通常是部分支持,或者说其对 C 调用规范的支持需要引入额外的开销。上面说了这么多理论的东西,我们来看一个实际的例子:

我们以 Python 和 C 语言交互为例,Python 本身是一种脚本语言,CPython 是 C 语言开发的Python解释器,接下来的例子我们都以 CPython 为例进行说明。大家如果学习过 Python,可能都会听说 Python 是一门胶水语言,可以非常方便的使用 C 语言开发的库,但是,要知道这层胶水也是有代价的。例如我们想在 Python 中调用一个现成的 C 语言开发的动态库,我们会写下面的代码:

ctypes
char *charchar *charstr
1+1
-buildmode=c-shared

而对于Rust开发的库文件呢?由于 Rust 几乎没有 Runtime,内存管理可以和操作系统默认保持一致,系统级编程语言可以直接和系统调用打交道没有额外开销,因此我们可以写出小而美的库文件。

通过上面的例子加深理解以后,我们就可以引出一些结论:

  • A类中的语言相互调用,往往是最原生、最高效的。
  • B类中的语言相互调用,往往伴随着巨大的额外开销,甚至开销过大以至于失去折腾这些跨语言调用的价值。
  • 每种语言都可能处于调用者和被调用者两种角色,而一门语言扮演两种角色时,其支持程度可能是不一样的:
  • B类语言通常都支持调用A类语言开发的库函数,而且开销相对较小,因为B类语言通常比较新,为了适配老生态,他们都会去主动靠拢支持与C语言开发的库的调用。
  • A类语言如果想调用B类语言开发的函数,就要看B语言的诚意,因为A类语言是前辈,它们对新的语言特性、实现方式一无所知,后辈们如果希望得到前辈的重用,那就要自己提供出满足前辈价值观的接口。
  • 通常,B类调用A类会容易实现一些(只需要做类型转换),而A类调用B类则会困难一些(需要初始化B类语言的运行时、解释器等)。多数语言对B类调A类的支持会优于A类调B类。

那么,回到这一小节的核心问题:Rust 在开发二进制库上的优势是什么?通过上述的分析,如果你希望自己开发的二进制库能够有最好的兼容性,能够被更多的语言来调用,那么最好选择用A类语言来开发,而A类语言中,大部分都是古董级别的语言。Rust是为数不多的【属于A类】的【新一代】开发语言,所以,我很看好它~

为什么选择Golang作为调用示例

  • Golang 目前应用非常广泛,熟悉 Golang 的小伙伴们也比较多,这样可能会有更多的受众。
  • 最近使用Rust或Go开发的云原生项目增长非常快,因为这两种语言的应用场景在云原生方面有重合,所以研究一下二者的融合就很有意思。
  • Golang 具有自己的运行时、自己的栈结构、自己的内存分配器,因此相比较于Python这类胶水语言,Golang调用CFFI函数库更有难度,我们喜欢做一些有挑战的事情。
  • 正因为其略微复杂,我们才可以更好的思考一些问题,更好的领会跨语言调用的核心思想。

配置一个Rust项目,使之能够编译出满足C-ABI的动态链接库

https://github.com/myrfy001/rust_golang_ffi_demoexample_1
rustgolang
cargo new --lib rustffi.rsmy_app.rs
Cargo.toml
crate-type = ["cdylib"]cdylibdylibdylib

再来介绍一下src目录下的结构:

my_app.rsmy_app.rsffi.rslib.rsmodmy_app.rsffi.rs
my_app.rs

这段代码非常简单,它是一个普通的 Rust 函数,将 3 个入参相加后返回,因为这是一个最简单的示例,所以入参选择了 3 个不同的基本数据类型。

ffi.rs
#[no_mangle]

虽然外部看起来函数名混淆之后变得人类难以理解,但rust编译器自己心里还是非常清楚这些对应关系的。但是,如果这个函数要被其他语言调用,其他语言在编译链接的时候可不知道rust编译器是怎么混淆的,其他语言的链接器只能拿着函数名去符号表里找,如果混淆了,肯定对不上,因此,在开发ffi接口时,必须告诉编译器,不能对这个函数的名字做混淆。

cargo buildtarget/debug/librust.solibrust.dyliblibrust.dyliblibrustCargo.tomlname = "rust"

开发一个Go项目,调用Rust编写的库

本篇文章虽然是介绍在 Go 中调用 Rust 编写的函数库,但其中以 C-FFI 为界,在 Golang 视角中,其看到的只是一个符合 C-FFI 标准的函数库,本篇文章介绍的与 cgo 有关的知识点,可以应用在其他任何语言所开发的符合 C-FFI 标准的软件库上,不仅限于 Rust 开发的软件库。
example_1golang
go.modgo mod initmain.gomain_test.goffi_demo.h
ffi_demo.h
ffi_demo.h
cbindgen
stdint.huintptr_t

文件的第二行,按照C语言的语法:

#[no_mangle]
ffi_demo.h/usr/local/includegolang
ffi_demo.hmain.go
import "C"import "C"

再来看一下上面注释中被cgo处理的内容:

#cgo CFLAGS: -I.-I..golangffi_demo.hffi_demo.h#cgo LDFLAGS: -L../rust/target/debug -lrust-L../rust/target/debug-lrustrustlibrust.so#include "ffi_demo.h"ffi_demo.h
import "C"Cmain.goC.xxxx
SimpleRustFuncCalledFromGo
arg1arg2arg3ffi_demo.hcArg1cArg2cArg3C.uint8_tC.uint16_tC.uint32_tC.simple_rust_func_called_from_gosimple_rust_func_called_from_goCsimple_rust_func_called_from_goffi_demo.hsimple_rust_func_called_from_goffi_demo.hlibrust.sosimple_rust_func_called_from_golibrust.solibrust.sosimple_rust_func_called_from_go#[no_mangle]C.ulongC.ulongffi_demo.huintptr_tC.ulongffi_demo.huintptr_tC.ulongif int(ret) != arg1 + arg2 + arg3 {if ret != arg1 + arg2 + arg3 {C._Ctype_ulongintC.ulong

以上,我们捋顺了go调用简单C函数的流程

arg1arg2arg3uint8uint16uint32
CC
C.ucharC.shortC.int
CC
main_test.go

字符串的传递

本小节将提升一些难度,为大家介绍跨语言传递字符串的方法。之所以难度会有提升,是因为相比上一关所有变量都是栈分配而言,本小节的字符串类型涉及到了堆内存的使用。而在使用堆内存的时候,必须处理好谁申请,谁释放的问题;除此之外,另一个头疼的问题就是C语言中的字符串规定以Null结尾,字符串本身就是一个指针,不包含长度信息,而Rust中的String也好,&str也好,都使用了额外的空间保存字符串长度,而真正的字符串在内存中不能保证是以Null结尾的,这就会引入额外的转换操作。

预防针
在接下来的内容中,会遇到一些非常规的做法,这里之所以要介绍这些非常规的做法,目的不是为了鼓励大家在代码中去这么使用,而是给大家展示各种各样的情况,让大家了解到无论上层接口再怎么变,使用方法再怎么稀奇古怪,其底层核心不变的就是内存的分配与释放。借助unsafe rust,大家可以获得像C/C++一样对内存的完全掌控能力。实际使用中FFI边界上的情况非常灵活多变,但只要内存管理能搞清楚,其他的事情都不是大问题。

了解库函数的核心功能逻辑

example_2rust/src/my_app.rs
String&str
  • 当输入字符串的长度小于15个byte的时候,返回完整的字符串,而超过15个byte的时候返回前15个byte
String&str

接收String,返回String

to_string()

如果没有发生堆内存分配,则函数返回前后的情况如下:堆内存被复用,入参对应的字符串头(栈内存)被释放,而返回一个新的字符串头(栈内存)。

出于篇幅的限制,后面不能每个case都画图来说明。在继续后面的介绍之前,大家一定要理解这个字符串在内存中的表示形式,并且在看到后面的代码时,能想想出对应的内存布局。

接收&str,返回String

&strStringString&str&strString

接收&str,返回&str

下面两条路径都没有涉及到资源分配

接收String,返回&str

这个函数通过Unsafe实现了安全Rust所绝对不允许的事情,即凭空返回一个堆内存的引用。再次提醒,这只是一个示例,为了帮助大家加深对Rust手动内存管理的理解,自己写代码的时候不要写这种容易被打的代码。这个函数两条路径也都没有进行堆内存分配。

安全的Rust之所以不允许在函数中返回一个Srting的引用,是因为在函数返回时,String会被drop,对应的堆内存被回收,导致引用到被回收的堆内存空间,而上面的代码中,通过ManuallyDrop的包装,我们使得String不会在函数返回时被drop,这样String所持有的堆内存就被“遗忘”了,于是我们就可以放心的引用这块堆内存了。之所以“遗忘”要打引号,是因为我们不能真的遗忘它,否则就是内存泄漏,我们得留一个线索,在必要的时候能够释放掉这块内存,而这个线索就是函数返回值的第2、3、4个参数,通过指针、容量、实际占用长度这三个参数,我们就可以重新在栈内存上构建出String头部的结构体,从而将“遗忘”的String找回来。

my_app.rs

包装字符串传递的FFI接口


rust/src/ffi.rs
my_app.rsString&str*const c_char
String&strchar*
String&strchar*String&strchar*String&strchar*String&strchar*
char*CStringCStr
CStringStringCStringCStrstr&CStrCStr

有了这些理论储备以后,我们可以开始看第一个FFI封装函数了:

receive_str_and_return_string:

大家看完上面这一顿操作以后,可能会对FFI接口的效率失去信心……我就是想传递一个字符串,怎么有这么多额外的开销啊……。

对于这个问题,大家先不要慌,上面给大家展示的流程可以说是最安全的一个流程,这些额外开销换来的是更高的安全性,为了优化,我们有几个方向可以尝试:

  • 通过人为的约定,减少一些不必要的检查
  • 通过调整API接口的设计,显式传递字符串长度
  • 通过调整API接口设计,将频繁的调用转化为少量批次调用,以减小开销
  • 避免在Hot Path上使用转换开销大的接口
    关于提升性能,我们会在文章后后面进一步讨论,通过做性能测试的方法,来对比不同方案的性能提升。

接下来还有两个函数,我们也依次来看一下他们在内存使用上有什么差异。为了缩短篇幅,相同的注释内容就被删除了。

receive_string_and_return_string:

receive_str_and_return_str

receive_string_and_return_str


!!!预警!!!这个API的设计非常丑陋,这里只是一个实例,千万不要在生产环境中写这样的代码!

这个接口中使用了二阶指针作为参数,这样做的目的是通过这些参数实现返回多个结果。在C语言的调用规范中,

是不允许一个函数有多个返回值的,为了返回多个结果,我们有两种方式:

  • 定义一个结构体来保存多个返回值的内容,然后返回指向这个结构体的指针
  • 通过传入指针来修改调用者的内存数据,从而将要返回的值写入到调用者给定的变量中
    这里我们的二阶指针就是使用了第二种方法。

内存释放的相关函数


into_raw()

一个零拷贝的FFI接口示例


my_app::my_app_receive_str_and_return_str()

课后作业:这里我们只是把返回数据的长度进行了额外的存储,但是传入的字符串仍然需要遍历才可以获得其长度。请尝试修改API接口,从而避免对输入字符串的遍历。

在Golang中调用


golang/ffi_demo.h
*const c_charchar*
golang/main.goimport "C"#include "stdlib.h"stdlib.hC.free()
PassStringBySinglePointer()

上面代码有几个要点:

C.CString()C.GoString()C.free()

最后,我们就可以通过下面的调用来验证效果了,可以看到,如果超过15个字节的部分,会被截断,而小于15个字节的话,会被原封不动的返回:

接下来,我们要看一下另一种函数签名的接口API如何使用,代码如下:

cStrRetcRawStrretCapretLen

最后,我们来看一下号称零拷贝的Rust FFI接口是如何使用的,代码如下:

可以看到,虽然这几个测试,输入一样,返回值也一样,但其内部的实现方式、对内存的使用方式,有着极大的差异,因此,讲到这里,大家或许会觉得我说的比较啰嗦,但是能够彻底理解他们之间的差异,是进行FFI开发必备的能力。如果大家还有不是很清楚的地方,一定要搞明白。

虽然上面给大家展示了几种不同的内存使用以及参数传递的方式,但是这并不是所有的排列组合,也不一定是最优雅的API接口设计。作为课后作业,大家可以思考一下,传递字符串的API接口还可以怎么设计,能够更加高效、更加简洁?