Rust 征服世界的道路注定不会一蹴而就,因此 Rust 需要能够像与自身代码交互一样轻松地与现有生态互操作。正因如此,Rust 可以轻松地与 C API 通信而没有开销,同时利用其所有权系统为这些 API 提供更强的安全保障。
为了与其他语言通信,Rust 提供了一种外部函数接口(FFI)。遵循 Rust 的设计原则,FFI 提供了一种零开销抽象,使得 Rust 和 C 之间的函数调用性能与 C 函数调用完全相同。FFI 绑定还可以利用所有权和借用等语言特性来提供一个安全接口,用于强制执行围绕指针及其他资源的协议。这些协议通常(至多)只出现在 C API 的文档中,但 Rust 使它们变得明确。
在这篇博文中,我们将探讨如何将不安全的 C FFI 调用封装到安全的零开销抽象中。然而,与 C 协作只是一个例子;我们还将看到 Rust 如何像与 C 一样轻松无缝地与 Python 和 Ruby 等语言通信。
Rust 与 C 通信
让我们从一个从 Rust 调用 C 代码的简单示例开始,然后演示 Rust 没有引入额外的开销。下面是一个简单的 C 程序,它会将所有输入加倍
int
要从 Rust 中调用这个函数,你可以这样编写程序
extern crate libc;
extern
就这样!你可以在 GitHub 上查看代码并从该目录运行 cargo run
来亲自尝试。在源代码层面,我们可以看到调用外部函数除了声明其签名外没有额外的负担,我们很快就会看到生成的代码确实也没有开销。然而,这个 Rust 程序中还有一些微妙之处,所以让我们详细介绍每个部分。
首先我们看到 extern crate libc
。libc crate 在与 C 通信时提供了许多有用的 FFI 绑定的类型定义,并且它使得确保 C 和 Rust 在跨越语言边界时对类型达成一致变得容易。
这很自然地引出了程序的下一部分
extern
在 Rust 中,这是一个可外部使用的函数的声明。你可以把它想象成一个 C 头文件。编译器在这里了解函数的输入和输出,你可以看到上面这与我们在 C 中的定义相符。
我们在这里看到了 Rust 中 FFI 的一个关键方面:unsafe
块。编译器对 double_input
的实现一无所知,因此它必须假定每当你调用外部函数时,可能会发生内存不安全。unsafe
块是程序员承担确保安全责任的方式——你承诺你实际进行的调用事实上不会违反内存安全,从而保证 Rust 的基本保障得到维护。这看起来可能有限制,但 Rust 拥有恰到好处的工具集,可以让使用者无需担心 unsafe
(稍后会详细介绍)。
既然我们已经看到了如何从 Rust 调用 C 函数,让我们看看是否能验证这个零开销的主张。几乎所有编程语言都可以通过某种方式调用 C,但这通常会伴随运行时类型转换或一些语言运行时处理而产生开销。为了了解 Rust 的行为,让我们直接查看上面 main
函数调用 double_input
的汇编代码
mov $0x4,%edi
callq 3bc30 <double_input>
和之前一样,就这样!这里我们可以看到,从 Rust 调用 C 函数只涉及将参数就位后的一条调用指令,这与在 C 中调用的开销完全相同。
安全抽象
Rust 中的大多数特性都与其核心概念“所有权”紧密相关,FFI 也不例外。在 Rust 中绑定一个 C 库时,你不仅获得了零开销的好处,而且还能使其比 C 更安全!绑定可以利用 Rust 中的所有权和借用原则,将通常在 C 头文件中关于如何使用其 API 的注释转化为代码。
例如,考虑一个用于解析 tar 包的 C 库。这个库会暴露函数来读取 tar 包中每个文件的内容,可能类似于下面这样
// Gets the data for a file in the tarball at the given index, returning NULL if
// it does not exist. The `size` pointer is filled in with the size of the file
// if successful.
const char *;
然而,这个函数隐式地做出了关于其使用方式的假设,它假定返回的 char*
指针不能比输入的 tar 包存活时间更长。当在 Rust 中进行绑定时,这个 API 可能看起来像这样
在这里,*mut tarball_t
指针被 Tarball
所有,Tarball
负责任何销毁和清理工作,因此我们已经对 tar 包内存的生命周期有了丰富的了解。此外,file
方法返回一个借用切片,其生命周期隐式地与源 tar 包本身的生命周期(即 &self
参数)相关联。这是 Rust 表明返回的切片只能在 tar 包的生命周期内使用的方式,静态地防止了在使用 C 时容易产生的悬垂指针错误。(如果你不熟悉 Rust 中的这种借用方式,可以看看 Yehuda Katz 关于所有权的博文。)
这里 Rust 绑定的一个关键方面是它是一个安全函数,这意味着调用者无需使用 unsafe
块来调用它!尽管它有一个 unsafe
实现(因为调用了 FFI 函数),但其接口使用借用来保证使用它的任何 Rust 代码中不会发生内存不安全。也就是说,由于 Rust 的静态检查,在 Rust 侧使用这个 API 根本不可能导致段错误。而且别忘了,所有这一切都是零开销的:C 中的原始类型可以在 Rust 中表示,没有额外的分配或开销。
Rust 杰出的社区已经在现有 C 库周围构建了一些重要的安全绑定,包括 OpenSSL、libgit2、libdispatch、libcurl、sdl2、Unix API 和 libsodium。这个列表在 crates.io 上也增长得相当快,所以你喜欢的 C 库可能已经有了绑定,或者很快就会有!
C 与 Rust 通信
尽管 Rust 保证了内存安全,但它没有垃圾回收器或运行时,这样做的好处之一是,Rust 代码可以被 C 调用,而无需任何设置。这意味着零开销 FFI 不仅适用于 Rust 调用 C 的情况,也适用于 C 调用 Rust 的情况!
让我们以前面的例子为例,但反转两种语言的角色。和之前一样,下面的所有代码都可以在 GitHub 上找到。首先,我们将从我们的 Rust 代码开始
pub extern
和之前的 Rust 代码一样,这里内容不多,但也有一些微妙之处。首先,我们用 #[no_mangle]
属性标记了我们的函数定义。这指示编译器不要对函数 double_input
的符号名称进行修饰。Rust 采用类似于 C++ 的名称修饰机制,以确保库之间不会冲突,这个属性意味着你无需从 C 中猜测像 double_input::h485dee7f568bebafeaa
这样的符号名称。
接下来是我们的函数定义,其中最有趣的部分是关键字 extern
。这是指定函数 ABI 的一种特殊形式,这使得函数能够与 C 函数调用兼容。
最后,如果你看看 Cargo.toml
,你会看到这个库不是被编译成普通的 Rust 库(rlib),而是被编译成 Rust 称为 'staticlib' 的静态归档库。这使得所有相关的 Rust 代码可以静态链接到我们即将生成的 C 程序中。
既然我们的 Rust 库已经准备就绪,让我们编写调用 Rust 的 C 程序吧。
extern int32_t ;
int
在这里我们可以看到,C 和 Rust 一样,需要声明 Rust 定义的 double_input
函数。除此之外,一切都准备好了!如果你从 GitHub 上的目录运行 make
,你会看到这些例子被编译并链接在一起,并且最终的可执行文件应该会运行并打印 4 * 2 = 8
。
Rust 没有垃圾回收器和运行时,这使得从 C 到 Rust 的无缝过渡成为可能。外部 C 代码无需为 Rust 执行任何设置,从而使得这种过渡更加廉价。
超越 C
到目前为止,我们已经了解了 Rust 中的 FFI 如何实现零开销,以及如何利用 Rust 的所有权概念为 C 库编写安全绑定。然而,如果你不使用 C,你仍然很幸运!Rust 的这些特性使得它也可以被 Python、Ruby、JavaScript 和更多语言调用。
在使用这些语言编写代码时,你有时会想加速一些性能关键的组件,但过去这通常需要直接回到 C,从而放弃了这些语言的内存安全、高级抽象和易用性。
然而,Rust 可以轻松地与 C 通信这一事实,意味着它也非常适合这类用途。Rust 的首批生产用户之一 Skylight,通过仅仅使用 Rust,几乎立即提高了他们数据收集代理的性能和内存使用率,而且 Rust 代码都发布为 Ruby gem。
从 Python 和 Ruby 等语言转到 C 来优化性能通常相当困难,因为很难确保程序不会以难以调试的方式崩溃。然而,Rust 不仅带来了零开销 FFI,而且也使得保留与原始源语言相同的安全保障成为可能。从长远来看,这应该会使得这些语言的程序员更容易在需要时转向进行一些系统编程以获得关键性能提升。
FFI 只是 Rust 工具箱中的众多工具之一,但它是 Rust 推广应用的关键组成部分,因为它允许 Rust 在今天无缝地与现有代码库集成。我个人非常期待看到 Rust 的好处能触及尽可能多的项目!