一次编写,随处运行

2015 年 4 月 24 日 · Alex Crichton

Rust 的世界统治野心并非一蹴而就,因此 Rust 需要能够像与自身交互一样轻松地与现有世界交互。为此,**Rust 使得与 C API 进行通信变得轻而易举,而且没有额外的开销,同时利用其所有权系统为这些 API 提供更强大的安全保障**。

为了与其他语言进行通信,Rust 提供了一个 *外部函数接口* (FFI)。遵循 Rust 的设计原则,FFI 提供了一个 **零成本抽象**,其中 Rust 和 C 之间的函数调用与 C 函数调用具有相同的性能。FFI 绑定还可以利用语言特性,例如所有权和借用,来提供一个 **安全接口**,该接口强制执行有关指针和其他资源的协议。这些协议通常只出现在 C API 文档中(充其量),但 Rust 使它们变得明确。

在这篇文章中,我们将探讨如何将不安全的 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 函数涉及在将参数移位到位后进行精确的一次调用指令,与在 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 的优势能够惠及尽可能多的项目!