一次编写,处处运行

2015年4月24日 · Alex Crichton

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 double_input(int input) {
    return input * 2;
}

要从 Rust 调用它,您可以编写如下程序

extern crate libc;

extern {
    fn double_input(input: libc::c_int) -> libc::c_int;
}

fn main() {
    let input = 4;
    let output = unsafe { double_input(input) };
    println!("{} * 2 = {}", input, output);
}

就是这样!您可以通过在 GitHub 上查看代码并从该目录运行 cargo run 来亲自尝试。在源代码级别,我们可以看到调用外部函数除了声明其签名之外没有任何负担,我们很快就会看到生成的代码实际上也没有任何开销。然而,这个 Rust 程序有一些细微之处,所以让我们详细介绍每一部分。

首先我们看到 extern crate libclibc crate为与 C 对话时的 FFI 绑定提供了许多有用的类型定义,并且可以很容易地确保 C 和 Rust 在跨越语言边界的类型上达成一致。

这很好地引导我们进入程序的下一部分

extern {
    fn double_input(input: libc::c_int) -> libc::c_int;
}

在 Rust 中,这是一个对外部可用函数的声明。您可以将其视为 C 头文件。在这里,编译器了解函数的输入和输出,您可以在上面看到这与我们在 C 中的定义匹配。接下来是程序的主体

fn main() {
    let input = 4;
    let output = unsafe { double_input(input) };
    println!("{} * 2 = {}", input, output);
}

我们在这里看到了 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 函数需要在将参数移动到位后精确地执行一个 call 指令,这与在 C 中的成本完全相同。

安全抽象

Rust 中的大多数特性都与所有权的核心概念相关联,FFI 也不例外。在 Rust 中绑定 C 库时,您不仅可以获得零开销的好处,而且还可以使其比 C 更安全绑定可以利用 Rust 中的所有权和借用原则来编纂通常在 C 头文件中找到的有关如何使用其 API 的注释。

例如,考虑一个用于解析 tarball 的 C 库。该库将公开读取 tarball 中每个文件内容的功能,可能类似于

// 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 *tarball_file_data(tarball_t *tarball, unsigned index, size_t *size);

然而,此函数隐含地假设了如何使用它,即假设返回的 char* 指针不能比输入的 tarball 活得更久。当在 Rust 中绑定时,此 API 可能如下所示

pub struct Tarball { raw: *mut tarball_t }

impl Tarball {
    pub fn file(&self, index: u32) -> Option<&[u8]> {
        unsafe {
            let mut size = 0;
            let data = tarball_file_data(self.raw, index as libc::c_uint,
                                         &mut size);
            if data.is_null() {
                None
            } else {
                Some(slice::from_raw_parts(data as *const u8, size as usize))
            }
        }
    }
}

在此处,*mut tarball_t 指针由 Tarball拥有,它负责任何销毁和清理,因此我们已经对 tarball 内存的生命周期有了丰富的了解。此外,file 方法返回一个借用的切片,其生命周期隐式地与源 tarball 本身的生命周期(&self 参数)相关联。这是 Rust 指示返回的切片只能在 tarball 的生命周期内使用的另一种方式,从而静态地防止在使用 C 直接工作时容易出现的悬空指针错误。(如果您不熟悉 Rust 中这种借用,请查看 Yehuda Katz 关于所有权的博客文章。)

Rust 绑定的一个关键方面是它是一个安全函数,这意味着调用者不必使用 unsafe 块来调用它!虽然它具有 unsafe 实现(由于调用 FFI 函数),但接口使用借用保证在任何使用它的 Rust 代码中都不会发生内存不安全的情况。也就是说,由于 Rust 的静态检查,根本不可能在 Rust 端使用 API 导致段错误。并且不要忘记,这一切都是零成本的:C 中的原始类型可以用 Rust 表示,无需额外的分配或开销。

Rust 的出色社区已经围绕现有的 C 库构建了一些重要的安全绑定,包括OpenSSLlibgit2libdispatchlibcurlsdl2Unix APIlibsodium。此列表在 crates.io 上也在快速增长,因此您最喜欢的 C 库可能已经绑定或即将绑定!

C 与 Rust 对话

尽管保证了内存安全,但 Rust 没有垃圾收集器或运行时,而其好处之一是 Rust 代码可以从 C 调用,而无需任何设置。这意味着零开销 FFI 不仅适用于 Rust 调用 C,也适用于 C 调用 Rust!

让我们以上面的示例为例,但反转每种语言的角色。与之前一样,以下所有代码都可在 GitHub 上获得。首先,我们将从 Rust 代码开始

#[no_mangle]
pub extern fn double_input(input: i32) -> i32 {
    input * 2
}

与之前的 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 程序。

#include <stdint.h>
#include <stdio.h>

extern int32_t double_input(int32_t input);

int main() {
    int input = 4;
    int output = double_input(input);
    printf("%d * 2 = %d\n", input, output);
    return 0;
}

在这里,我们可以看到 C 和 Rust 一样,需要声明 Rust 定义的 double_input 函数。除此之外,一切都准备就绪!如果您从GitHub 上的目录运行 make,您会看到这些示例被编译并链接在一起,最终的可执行文件应运行并打印 4 * 2 = 8

Rust 缺乏垃圾收集器和运行时使得从 C 到 Rust 的无缝过渡成为可能。外部 C 代码不需要代表 Rust 执行任何设置,从而使过渡成本更低。

超越 C

到目前为止,我们已经了解了 Rust 中的 FFI 如何具有零开销,以及如何使用 Rust 的所有权概念来编写 C 库的安全绑定。但是,如果您不使用 C,您仍然很幸运!Rust 的这些特性使其也可以从 PythonRubyJavaScript 和更多语言中调用。

在这些语言中编写代码时,您有时希望加快某些性能关键的组件的速度,但在过去,这通常需要一直降到 C,从而放弃这些语言的内存安全性、高级抽象和人体工程学。

然而,Rust 可以轻松地与 C 通信的事实意味着它也适用于此类用法。Rust 的第一个生产用户之一 Skylight 仅通过使用 Rust 几乎立即提高了其数据收集代理的性能和内存使用率,并且 Rust 代码全部作为 Ruby gem 发布。

从 Python 和 Ruby 这样的语言转向 C 语言以优化性能通常相当困难,因为很难确保程序不会以难以调试的方式崩溃。 然而,Rust 不仅带来了零成本的 FFI,而且使得保持与原始源语言相同的安全保证成为可能。 从长远来看,这应该会让这些语言的程序员更容易在需要时进行系统编程,以榨取关键性能。

FFI 只是 Rust 工具箱中的众多工具之一,但它是 Rust 被采用的关键组成部分,因为它允许 Rust 今天就与现有的代码库无缝集成。 我个人非常期待看到 Rust 的优势能够惠及尽可能多的项目!