零成本抽象

Edit on GitHub

Rust 零成本抽象

cathaysia

零成本抽象

零成本抽象有两种含义:

  • 用户不会为未用到的功能付费。也就是按需付费。
  • 使用高级编程特性不会带来比低级编程带来更多的性能损失。

下面分别解释这两个含义

按需付费

协程

Rust 中按需付费中最具代表性的就是协程。下面我会对 Rust/Golang 中协程的实现进行横向对比。

运行时开销

在不使用协程的情况下,Rust 用户无须承担协程 runtime 带来的性能损失和体积损失。

golang 中的协程是内置到语言中的,无论是否用到协程,用户都必须承受协程 runtime 带来的开销,这部分开销包括性能开销和体积开销等。

性能开销

Rust 中的协程是无栈协程。编译器将用户写的 async 函数在编译时转换为状态机,然后由 runtime 去 poll。Rust 中协程的切换只会发生在 .await 出现的地方。

golang 是有栈协程,由编译器在代码中进行埋点来检查是否需要切换。例如 golang 在函数调用时会进行埋点,检查是否需要进行协程切换,这意味着无论是否需要切换,用户都需要承担检查带来的性能负担。

标准库

和其他语言不同,Rust 将标准库分为了两部分:

  • core: Rust 中提供的不依赖任何平台的实现。包括指针和内存操作等。
  • std: 包含了 core、alloc 等库,包含了内存分配、平台特定实现。

默认情况下 Rust 已经包含了 std 库,但是可以通过 no_std 将标准库去掉,而 core, alloc 的库可以按需引入,从而允许用户:

  1. 在裸金属(无操作系统)上运行。
  2. 在无内存分配器的系统上运行。

因此用户可以只使用 Rust 本身的语言特色,而无需存在标准库带来的开销。

高级编程特性

Rust 用户可以使用更高级的编程特性而无需考虑性能损失。

函数内联

尽管内联函数在高级编程语言中已经很常见了,但是这里还是需要提一句。函数内联可以任意地拆分函数而无需担心小函数带来的调用开销。

动态分发

Java、C# 等语言,所有的函数默认都是动态分发的。而 Rust 则尽可能执行静态分发。除非你明确用到了动态分发,否则不需要承受动态分发带来的开销(例如 vtable 带来的体积开销和多级指针引用导致的性能开销)。

这里简单介绍一下动态分发的原理。

动态分发意味着在运行时查找函数的实现。这一般出现在两种情况下:

  1. 子类覆盖了父类的函数。这是通过父类指针操纵子类对象,需要通过动态分发查找实际需要调用的函数。
  2. Rust 中不存在继承。但是依然存在 trait(interface) 对象。当通过 trait 指针操纵对象时也会用到动态分发。

C++ 中动态分发的机制是创建一个 vtable,将父类、子类所有的(虚)函数注册到这个表中。然后在运行时通过查表的形式查找需要调用的函数。vtable 在全局只有一份。但是 C++ 会在对象中(一般是对象头部)插入一个 vptr 来查找这个表。

而 Rust 使用胖指针的形式。在指针中包含 vptr,从而允许普通对象无需承担 vptr 带来的开销。

所有权机制和生命周期

Rust 中最著名的两个特色:所有权机制和生命周期检查。这两者都发生在编译期,不会带来任何性能损失。

ZST(Zero Size Type)

Rust 存在多种 ZST 类型,这些 ZST 包括:

  • 空元组:()
  • 空数组:[u8; 0]
  • 空结构体:struct A;

这些 ZST 类型不占用任何空间。对比 C++ 而言,C++ 的对象至少占据一个字节的空间。

ZST 类型的出现也为编译器提供了更好的优化方法。因此 Rust 创建 HashSet 只需要:

struct HashSet<T>(HashMap<T, ()>);

编译器知道 ZST 的存在,因此可以对 HashSet 执行更好的优化。

总结

零成本抽象使得 Rust 在成为一个现代编程语言的同时没有损失性能。从而允许将 Rust 广泛部署到各种应用场景中。无论是网络服务,还是嵌入式硬件,Rust 都能够