For the complete documentation index, see llms.txt. This page is also available as Markdown.

Rust 比磨损更好

同事首次向我热情推荐 Rust 时,我心存怀疑。早年间我为安全关键系统开发软件,吃过苦头后才明白:编程语言常不如表面看上去那么正常。以 C 为例,尽管标准化工作可观,C 规范仍充斥未指定、未定义与实现定义的行为 [2]。即便到了 2016 年,研究人员仍在探究 C ISO 标准与实际用法之间的差异 [4]。并非所有软件工程师都需关注位字段以非 int、signed int 或 unsigned int 类型声明时会发生什么(这是未定义行为 [2])这类看似冷僻的问题,但我与安全关键及安全系统打交道太久,这部分较真无法关掉。于是略带轻蔑,我把 Rust 与 Go、Haskell 等听起来很酷但我永远用不上的技术归为一类。今年早些时候,我有机会重新审视 Rust,发现自己有些草率。

我当时在为基于 DTrace 的分布式追踪框架开发原型。原型用 C 编写,作为 DTrace 消费者(与 libdtrace 交互),通过 Apache Kafka 将 DTrace 记录向上游发送以便进一步处理(聚合、重排序等)。原型阶段还算好用,但随着工作推进,我需要快速探索设计空间。这一任务更适合用高级语言,但该选哪一种?像所有靠谱工程师一样,我开始列需求。我需要一门强调程序员生产力的语言,需要能轻松高效地与 C 库(如 libdtrace)交互,需要便于部署,因此需要庞大运行时的语言(尤其是 Java)直接出局。理想情况下,要良好支持并发并预防数据竞争。最后,戴上安全帽子,我不想因引入一堆可利用漏洞而丢人。我回想起与同事早先的对话:这些需求不正是 Rust 所针对的吗?于是我决定试一试 Rust,所幸我这么做了,因为我非常喜欢 Rust。那么 Rust 究竟是什么?

Rust 的愿景很简单——为 C++ 提供安全替代品,让系统程序员更高效,让关键任务软件更不易出 bug,让并行算法更易处理。Rust 的主要益处有 [5]:

  • 零成本抽象、保证内存安全(无需垃圾回收)、线程间无数据竞争;

  • 类型推断;

  • 极简运行时,高效的 C 绑定。

Rust 语言已有不少详尽教程,尤其“Rust Book”[5]。因此本文不再重复,而是着重介绍我觉得特别有吸引力的特性。其间会讨论最难掌握的 Rust 特性。最后,演示如何在 FreeBSD 上开始 Rust 编程。

与借用检查器搏斗

在动手打开你最爱的文本编辑器(当然是 vim)之前,必须先理解 Rust 最显著的代价:陡峭的学习曲线。在这条学习曲线上,没有什么比反复触怒“借用检查器”(borrow checker,即 Rust 所有权系统的名义执行者)更让人抓狂的了。所有权是 Rust 最引人注目的特性之一,是 Rust 内存安全保证的基石。在 Rust 中,变量绑定(将值绑定到名称)拥有其所绑定值的所有权。所有权是互斥的,即资源必须只有一个所有者。借用检查器的职责就是维护这一不变式,方式是尽早(编译期)且大声地失败。

下面这个例子取自“Rust Book”(The Rust Programming Language,2016),v 绑定到向量 vec![1, 2, 3]vec![1, 2, 3] 是一个 Rust 宏,创建包含 1、2、3 的连续可增长数组)。函数 foo() 是变量绑定 v 的“拥有作用域”。v 进入作用域时,在栈上分配新向量,元素则在堆上;作用域结束时,v 的内存(栈上与堆上的部分)自动释放。哦也,无需垃圾回收的内存安全。

fn foo() {
    let v = vec![1, 2, 3];
}

所有权可通过赋值 let x = y 转移(移动语义)。但记住所有权互斥,所以下面示例中,变量 v 在所有权转移给 v2 之后被引用(在宏 println 中)时,借用检查器会大声抗议 error: use of moved value: 'v'

let v = vec![1, 2, 3];
let v2 = v;
println!("v[0] is: {}", v[0]);

下面示例中,调用 bar() 并把向量 v 作为参数传入,会转移 v 的所有权。当拥有作用域(函数 bar)结束时,v 的内存自动释放(同前)。可从 bar 返回 v 把所有权还给调用者。这种做法很快会让人厌烦,因此 Rust 允许借用引用(即“借用”变量绑定的所有权)。被借用的绑定在自身作用域结束时不会释放资源。这意味着在调用 bar() 之后,我们仍可使用最初的绑定:

默认不可变

Rust 的变量绑定默认不可变。在 C 与 Java 中分别敲了无数 const * constfinal 之后,这一特性本身就让我欣喜;更重要的是,与 const 不同,它确实提供了不可变性。可用 mut 关键字将变量绑定指定为可变:let mut x = 10(也注意类型推断的合理使用)。与变量绑定一样,引用默认不可变,可用 mut 关键字变为可变(&mut T)。共享可变状态会引发数据竞争。Rust 通过强制以下二者之一来防止共享可变状态:

  • 对资源的一个或多个引用(&T),或

  • 恰好一个可变引用(&mut T)。

选择你的保证

Rust 的哲学是让程序员掌控保证与代价。Rust 关于“一个或多个不可变引用或恰好一个可变引用”的规则在编译期强制执行。但与整体哲学一致,也支持运行期与编译期强制之间的多种权衡。引用计数指针(Rc<T>)允许多个“拥有”指针指向同一(不可变)数据;只有所有引用计数指针都离开作用域时,数据才被丢弃、内存才被释放。这在只读数据被共享且无法确定所有消费者何时访问完毕时很有用。引用计数指针给出的保证(所有拥有指针离开作用域时内存才释放)与所有权系统在编译期强制的保证不同。但这伴随额外代价(维护引用计数的内存与计算开销)。类似地,可变状态也可共享(使用 Cell<T> 类型),这又带来保证与代价的不同权衡。

生命周期

所有权还有一个相当微妙的最后议题。变量绑定存在于其拥有作用域内,对这些绑定的借用引用也存在于各自独立的作用域内。变量绑定离开作用域时,所有权放弃,内存自动释放。那么如果变量绑定离开作用域时,被借用的引用仍在使用,会怎样?一言以蔽之,糟糕的事情会破坏 Rust 的内存安全保证。因此这种情况不能允许发生。生命周期是 Rust 防止借用引用比原资源活得更久的机制。

在 Rust 中,每个引用都有相关联的生命周期。但生命周期常常可以省略。下面示例展示了引用 s 的生命周期('a)省略形式与显式形式等价的语法:

全局变量很可能是 Rust 新手与生命周期的首次遭遇。全局变量用 Rust 特殊的静态生命周期声明:static N: i32 = 5;。静态生命周期表示变量绑定拥有整个程序的生命周期(注意字符串字面量拥有 &'static str 类型,因此活到程序结束)。如果让我猜生命周期下次在哪里冒头,那大概是在 struct 中存储引用。在 Rust 中,struct 用于创建复杂(复合)数据类型。当 Rust 的 struct 包含引用(即它借用了所有权)时,必须确保对该 struct 的任何引用不会比 struct 所持有的引用活得更久。因此,Rust struct 的生命周期必须等于或短于它所包含的任何引用的生命周期。

高效继承

与 C++ 和 Java 重量级的继承方式不同,Rust 采取低调策略,事实上“继承”一词被刻意回避。传统继承消失了,AWOL 的类也就不再需要。从类的束缚中解脱出来,方法可定义在任何地方,类型可有任意的方法集合。与 Go 一样,Rust 中的继承已简化为共享一组方法签名(这种方式有时被称为“无类对象”)。Rust 的 Trait 将一组方法签名聚合在一起——一个 Rust 类型可实现任意一组 Trait(因此 Trait 类似 mixin)。

与借用检查器再搏斗

为何 Rust 的所有权系统如此难以掌握?所有权并非 Rust 语言引入的复杂性,而是编程本身的内在复杂性,与所用语言无关。不处理所有权的语言会在运行期失败(数据竞争等)。相比之下,Rust 让所有权问题显式化,使语言能在编译期尽早且大声地失败。Rust 的借用检查器就像那种初次见面时你处不来的朋友。日久天长,他们多次帮你解围之后,你才意识到他们其实有不少好品质,幸亏结识了他们。

外部函数接口(FFI)

Rust 另一个特别吸引我的特性是高效 C 绑定支持(从 Rust 调用 C 代码无额外开销)。高效 C 绑定支持软件的增量重写,使程序员能利用大量短期内不会消失的 C 代码。外部函数不在 Rust 保护之下,因此始终被视为不安全(注意,有许多行为如死锁与整数溢出是不希望发生的,但按 Rust 的语义并非显式不安全)。在 Rust 中,不安全动作必须放在 unsafe 块中。在 unsafe 块内,Rust 那位更野的表亲“Unsafe Rust”统治一切。“Unsafe Rust”可以打破有限的一组 Rust 常规规则,其中最重要的是允许调用外部函数。

实践中,从 Rust 调用 C 函数并不总是像教程说的那么简单。考虑调用 dtrace_open()(来自 libdtrace)。dtrace_open() 的 C 原型如下:

要从 Rust 调用 dtrace_open(),先在 extern 块中声明 dtrace_open() 的签名(extern "C" 表示调用使用平台 C ABI)。然后可直接在 unsafe 块中调用该函数,如下:

但存在一个重大问题:类型 dtrace_hdl_t 定义在哪里?虽然 dtrace_hdl_t 可手工声明,但它包含许多字段,又用到更多必须定义的新类型。手工声明这一切极为繁琐且易错。所幸有解决方案:可用 Rust 的 bindgen crate 自动生成 C 绑定(cargo install bindgen)。可惜 bindgen 工具不太成熟,因此常需手动调整其输出(通常是增删可变性)。鉴于 SWIG 对 Rust 的支持短期内无望,迫切需要更好的原生工具来生成 Rust 绑定。

包管理

吸引我转向 Rust 的最后一个、也是最重要的特性,是对现代应用软件包管理的支持。Rust 提供灵活的 crate 与模块系统,用于组织、切分软件并管理可见性。Rust crate 等同于其他语言的库或软件包,Rust 模块则切分 crate 内的代码。一个 Rust 程序通常由单个可执行 crate 组成,可选依赖一个或多个库 crate。可复用的、社区开发的库 crate 托管于 crates.io——Rust 软件包管理工具 cargo 的中央软件包仓库(crates.io 大致相当于 Python 的 PyPI)。Rust 的 cargo 工具从 crates.io 拉取项目构建依赖并管理软件构建。是啊,我知道,世界真的需要又一套打包软件、解决依赖、构建软件的机制吗?也许不需要,但 cargo 确实挺好用(不过对用惯 Maven 的人来说,门槛本来也不高)。

在 FreeBSD 上起步

Rust 的平台支持分为三层,各提供不同保证。x86_64 上的 FreeBSD 目前是 Tier 2 平台,即保证能构建,但不保证能用。尽管没有保证,实践中通常都挺好用。Tier 2 平台提供 Rust 编译器 rustc 与标准库 std(pkg install rust)、软件包管理器 cargo(pkg install cargo)的官方发布。FreeBSD 的 Rust 二进制包目前(撰文时)为 v1.12,而最新稳定版是 v1.13。安装后,可执行 rustup 脚本将 Rust 更新到最新版本:

32 位 FreeBSD 处于 Rust 低下的第三层,既不保证能构建也不保证能用,状况相当不稳定。例如,Rust 1.13 在 ARM 平台上明明有严重的代码生成 bug(涉及硬件浮点)却照常发布。此处有龙,慎之!

现状如何?

Rust 于 2009 年作为 Mozilla 员工 Graydon Hoare 的个人项目起步。随后几年,Rust 转型为 Mozilla 赞助的社区项目,拥有超过 1200 名贡献者。自 2015 年 6 月发布的 1.0 版本以来,Rust 已用于多个实际部署。2016 年 6 月,Mozilla 在 Firefox 48 中为 Servo 渲染引擎交付了 Rust 代码,这是通向成熟的又一个重要里程碑。

人们在用 Rust,但它是否真正兑现了提供 C++ 安全替代方案的愿景?我认为答案基本是肯定的,尽管差异并非那么巨大。例如,在 C++ 中,unique_ptr 拥有并管理一个对象,当 unique_ptr 超出作用域时销毁该对象。此外,所有权可通过 std::move 转移;作为额外好处,还有使用 auto 关键字的类型推断。尽管有这些相似之处,智能指针并不能提供 Rust 所有权系统的一切。在下面的例子 [3] 中,move 之后访问 orig 会导致运行时段错误——在 Rust 中道德等价的例子将无法编译。及早失败是好事。认真的、熟练的 C++ 程序员不会犯这样的错误,这种说法多少有些循环论证的意味,因为如果这类错误不普遍,试图阻止它们的语言根本不会存在。C++ 还缺乏模块系统,并有一些相当糟糕的特性,如头文件和文本包含。这些都是 Rust 的优势。

Rust 与 C++ 在性能上相比如何?比较惯用 C++ 和 Rust 性能的对照研究很难找到。Firefox 的 Servo 和 Gecko 渲染引擎(分别用 Rust 和 C++ 编写)之间的比较报告称,Servo 引擎的速度大约快一倍 [1]。虽然这些数字应持保留态度,但共识是 Rust 在性能上至少与 C++ 相当。原因之一是 Rust 的真正的不可变性等特性允许进行 C++ 中无法实现的优化。Rust 的语义还为进一步优化带来巨大潜力。

尽管在生产环境中部署 Rust 取得了进展,问题仍然存在。Rust 的 ABI 不稳定;与 Glasgow Haskell 编译器一样,稳定的 ABI 可能永远不会实现,几乎可以肯定短期内不会。这个问题对 Rust 原生共享库影响最大,因为没有稳定的 ABI,它们在主要版本变更之间不兼容。ABI 不稳定并非致命问题。那么是否存在将 Rust 代码上游到 FreeBSD 的技术障碍?在我看来没有,但我有兴趣听听其他人对这样做在技术和政治层面的挑战的看法。

我喜欢 Rust。它很有趣。而这不正是让我们早上来到办公室的真正原因吗?


Graeme Jenkinson 是剑桥大学计算机实验室的高级研究员,领导 Causal, Adaptive, Distributed, and Efficient Tracing System(CADETS)项目的分布式追踪开发。在从事 CADETS 之前,他在国防和汽车行业拥有 13 年的经验。

最后更新于