Rust 为什么快:从所有权语义到机器码的链路分析

Rust 的高性能不是某个单点特性,而是所有权/借用、内存布局、单态化与 LLVM 优化共同作用的结果。本文拆开这条链路,也拆开它的代价。

14 分钟Rust性能系统编程LLVM

一个常见的迁移叙事是:服务从 Go 或 Java 换到 Rust,p99 降了,内存稳了,CPU 也降了。听上去像“Rust 比较快”,但这句话几乎无法验证——快在哪、对谁快、代价是什么,都被省略了。

更准确的说法是:Rust 把一组性能关键决策从运行时搬到了编译期,并让编译器拿到了其他语言不容易拿到的信息。 这篇文章沿着一条链路拆:内存管理 → 别名 → 分派 → 布局 → 机器码。每一层都说明它为什么可能更快,以及什么时候不成立。

先把“快”拆开

“快”至少是四个不同的指标,混在一起谈就会得出错误结论。

维度Rust 的典型表现主要原因
吞吐取决于实现,可能高也可能低无 GC、布局可控、易内联;但 clone、锁、动态分派会拖累
p99 / 尾延迟通常更稳没有 stop-the-world GC,析构时机确定,内存可控
内存与启动通常更低、更快无 GC 元数据,默认不装箱,AOT 编译,无 JIT 预热
编译与迭代通常更慢单态化、借用检查、LLVM 优化

所以“Rust 快”最准确的版本是:它更容易做到可预测的低延迟和低内存,同时把性能成本暴露在代码里。 至于绝对吞吐,语言只提供上限,代码决定实际值。

没有 GC:不是免费内存,而是把成本放到确定的位置

Rust 最常被提到的性能特征是“没有 GC”。这句话对,但不完整。

没有 GC 意味着没有对象图遍历、没有写屏障、没有 stop-the-world 暂停。内存释放由所有权和 Drop 在确定的作用域边界完成:

{
    let mut buf = Vec::with_capacity(4096);
    buf.extend_from_slice(payload);
    send(&buf);
} // buf 在这里确定释放,不需要等下一次 GC

这段代码的性能优势不只是“没有 GC 线程”,而是释放时机可预测:请求结束,内存就归还;不会在第 10 万个请求时突然来一次全局停顿。对 p99 敏感的服务,这通常比平均吞吐更重要。

但“没有 GC”不等于“没有内存管理成本”:

  • Vec、String、Box 仍然要分配和释放;
  • Drop 仍然要执行,析构函数里有锁或 I/O 一样会慢;
  • Rc/Arc 是引用计数,原子操作和缓存行竞争都是真实成本;
  • 循环引用会泄漏,mem::forget 也会泄漏;
  • 分配器本身可能成为瓶颈,多线程下尤其明显。

另一个常见误解是“GC 一定比手动管理慢”。在分配密集、对象生命周期短的负载里,GC 的 bump allocation 和分摊回收可以非常快,甚至比逐对象 malloc/free 更快。Rust 的优势通常不在“所有场景吞吐更高”,而在尾延迟、内存上限和成本可见性。

方式释放时机典型优势典型代价
Tracing GC不确定,批量回收分配快、开发简单STW/并发开销、内存峰值高
所有权 + RAII作用域结束,确定低延迟、内存可控需要设计所有权,编译期约束多
Rc / Arc最后一个引用消失共享所有权计数开销、循环泄漏、Arc 原子竞争

Rust 让你能选,但选错一样慢。把每个请求都塞进 Arc<Mutex<HashMap>>,你只是用 Rust 写了一个带锁的 GC 语言。

所有权与借用:给 LLVM 的别名证明

这是 Rust 性能讨论里最容易被低估的一层。

Rust 的借用规则是:同一时刻,一个值要么有多个共享引用 &T,要么只有一个可变引用 &mut T。这条规则首先是为了内存安全,但它同时给编译器提供了一个极强的信息:&mut T 指向的内存,在它的生命周期内不会被别的指针别名访问。

LLVM 有一个 noalias 属性,含义是“这个指针指向的对象,不会通过其他指针被读写”。C 语言里对应的东西是 restrict,但 restrict 是程序员口头承诺,编译器无法验证;写错了就是未定义行为。Rust 的借用检查器则在编译期强制了这件事(unsafe、UnsafeCell、RefCell、Mutex 等显式逃逸口除外)。

看一个典型的向量加法:

pub fn axpy(a: f32, x: &[f32], y: &mut [f32]) {
    debug_assert_eq!(x.len(), y.len());
    for (yi, xi) in y.iter_mut().zip(x) {
        *yi += a * *xi;
    }
}

在 Rust 里,x 和 y 不可能指向同一块内存:一个共享引用、一个可变引用,借用检查器不会允许这种调用。LLVM 因此可以放心地把循环向量化,不必为“y 的写入是否会影响 x 的读取”插入保守的依赖检查。

同样的循环在 C 里,如果参数只是 const float *x, float *y,编译器必须假设两者可能重叠,通常只能生成标量版本;除非你加上 restrict,而那是一个没有检查的承诺。

这不是说 Rust 一定生成更好的机器码,而是说:Rust 把“不会别名”从程序员的祈祷变成了类型系统可检查的默认值。 在热循环、数值计算、图像处理、解析器这类代码里,这个差别会持续复利。

代价也要说清楚:

  • unsafe、裸指针、UnsafeCell 会重新引入别名可能;
  • RefCell、Mutex、原子类型把检查移到运行时;
  • noalias 在 rustc/LLVM 历史上有过误编译 bug,它不是“免费的正确性”;
  • 不是每个 &mut 参数都会带上 noalias,取决于 ABI、参数位置和优化阶段。

但方向是明确的:借用检查不只是安全特性,它也是一个优化信息源。

零成本抽象:单态化、内联与迭代器

“零成本抽象”经常被误解成“抽象没有成本”。更准确的定义是:你不使用的抽象不付费;你使用的抽象,其成本应当和不使用它时相当。

Rust 做到这一点的主要手段是单态化(monomorphization):泛型函数在编译期为每个具体类型生成一份专用代码。于是:

  • 静态分派没有 vtable;
  • 泛型参数的具体类型已知,可以内联;
  • 迭代器、闭包、Option、Result 都能被展开成普通控制流;
  • 没有隐式的装箱和虚调用。
pub fn sum(values: &[u64]) -> u64 {
    values.iter().copied().sum()
}

这段代码没有分配,没有虚调用;iter().copied().sum() 通常会被优化成和手写循环相同的机器码。迭代器不是“语法糖带来的额外一层”,它在编译后就是一个状态机,甚至会被完全内联掉。

对比静态分派和动态分派:

trait Encoder {
    fn encode(&self, input: &[u8]) -> Vec<u8>;
}

fn encode_static<E: Encoder>(encoder: &E, input: &[u8]) -> Vec<u8> {
    encoder.encode(input)
}

fn encode_dynamic(encoder: &dyn Encoder, input: &[u8]) -> Vec<u8> {
    encoder.encode(input)
}

encode_static 在调用点知道具体类型,可以内联 encode;encode_dynamic 需要查 vtable,通常无法跨未知类型内联。不是说 dyn 一定慢,而是它把优化边界画在了编译期之外。

单态化的代价同样真实:

  • 代码膨胀:每个类型一份实例,二进制和 i-cache 都会变大;
  • 编译变慢:泛型越多,LLVM 要优化的副本越多;
  • 过度内联可能挤出 i-cache,反而变慢;
  • #[inline] 只是提示,不是命令。

所以“零成本抽象”不是“抽象免费”,而是“成本可预测,且通常能降到和手写代码同一量级”。

内存布局:缓存是性能的一半

Rust 默认不把对象装箱。Vec<T> 是连续内存,Box<T> 才是一次堆分配加指针间接。这个默认值对缓存非常友好:遍历 Vec<Item> 是顺序访问,遍历 Vec<Box<Item>> 则是一次又一次指针跳转。

Rust 还允许你控制布局:

use std::mem::size_of;
use std::num::NonZeroU32;

fn main() {
    assert_eq!(size_of::<Option<Box<u64>>>(), size_of::<usize>());
    assert_eq!(size_of::<Option<NonZeroU32>>(), size_of::<u32>());
    assert_eq!(size_of::<Option<u32>>(), 2 * size_of::<u32>());
}

Option<Box<T>> 用空指针作为 None,所以和指针一样大;Option<NonZeroU32> 用 0 作为 None,所以和 u32 一样大;Option<u32> 没有可用的 niche,只能额外带一个标签,于是变成 8 字节。这不是微优化,在数组、枚举、协议解析里,它直接影响缓存命中率。

类型布局策略典型大小说明
Option<Box<u64>>空指针 niche8 字节指针大小
Option<NonZeroU32>零值 niche4 字节无额外标签
Option<u32>标签 + 值8 字节没有 niche
Vec<T>指针 + 长度 + 容量24 字节连续堆内存

需要 FFI 或精确布局时,用 #[repr(C)]:

#[repr(C)]
struct Header {
    magic: u32,
    version: u16,
    flags: u16,
}

默认的 repr(Rust) 允许编译器重排字段,不要依赖字段顺序做序列化或 FFI。反过来,这也意味着 Rust 可以为了对齐和缓存做布局优化——前提是你不强行用 repr(C) 把它锁死。

布局层面还有几个常用手段:

  • Vec::with_capacity 一次分配,避免增长时多次拷贝;
  • SmallVec / ArrayVec 把小集合放在栈上,避免堆分配;
  • Cow<'a, str> 在“通常不需要修改”的路径上避免克隆;
  • SoA(struct of arrays)替代 AoS(array of structs),提高顺序扫描的缓存利用率;
  • #[repr(transparent)] 用于 newtype,保证和内部类型相同布局。

Rust 给了你控制权,但不会替你选择。Vec<Box<T>> 会把连续内存的优势还回去,HashMap 的哈希和探测成本也不会因为语言是 Rust 就消失。

安全检查的运行时账单

Rust 的安全不是零成本,它会在几个地方留下运行时检查:

  • 数组/切片索引的边界检查;
  • RefCell 的借用计数;
  • Rc/Arc 的引用计数;
  • Mutex/RwLock 的加解锁;
  • 整数溢出检查(debug 默认开,release 默认关);
  • panic 展开(unwind)的着陆垫和元数据。

边界检查是最常被讨论的。迭代器写法通常能让 LLVM 证明索引不越界,从而消掉检查:

pub fn sum(values: &[u64]) -> u64 {
    values.iter().copied().sum()
}

但复杂控制流、动态索引、跨函数调用之后,检查可能保留。需要极限性能时,可以用 get_unchecked,但那是 unsafe,正确性责任回到你身上。

panic 也有成本。默认的 unwind 会生成额外的元数据和着陆垫;设置 panic = "abort" 可以减小二进制、简化代码生成,但代价是失去展开恢复能力,测试和某些库也会受影响。这不是“开了就更快”,而是用语义换空间和可预测性。

判断标准很简单:先看检查是否在热路径上,再看它占了多少时间。 大多数业务代码的瓶颈不在边界检查,而在分配、锁、I/O 和序列化;反过来,在一个每帧处理百万像素的循环里,一次边界检查也可能值得处理。

并发:不数据竞争,不等于不慢

Rust 的 Send/Sync 把数据竞争变成了编译错误。这让你更敢写并发,但它不保证并发一定快。

模型适合主要成本
OS 线程CPU 密集型、需要隔离栈空间、上下文切换
async 任务高并发 I/O状态机、运行时调度、Pin
rayon 并行迭代数据并行工作窃取、任务划分
Arc<Mutex<_>>共享可变状态锁竞争、原子操作、伪共享

几个常见误区:

  • Arc<Mutex<HashMap>> 不是并发数据结构。 所有请求抢一把锁,核心越多竞争越厉害;分片、DashMap、thread-local 往往更有效。
  • async 不会让单次操作更快。 它让等待 I/O 的任务不占线程,提高并发度;把 CPU 密集任务放进 async executor,会阻塞整个运行时。
  • 无数据竞争不等于无竞争。 锁竞争、伪共享、内存序、死锁,类型系统都不管。
  • Rc<RefCell<T>> 是单线程的运行时借用检查。 图结构里大量使用它,性能很容易被计数和检查吃掉。

Rust 的并发优势是“敢优化”:编译器保证没有数据竞争,你可以放心地做无锁结构、分片、线程池,而不必先花一周排查偶发崩溃。但架构仍然是架构。

编译器后半段:LLVM、LTO、PGO、AOT

Rust 的前端把代码降到 MIR,做借用检查和部分优化,再交给 LLVM。真正的高性能机器码,很大一部分来自 LLVM 的优化管线。

几个关键开关:

[profile.release]
lto = "fat"
codegen-units = 1
panic = "abort"
  • lto = "fat":跨 crate 内联和优化,通常能提升性能,但链接时间明显变长;
  • codegen-units = 1:把更多代码放进同一个优化单元,优化更彻底,编译更慢;
  • panic = "abort":去掉展开路径,减小体积,改变错误恢复语义;
  • -C target-cpu=native:针对本机 CPU 生成指令(如 AVX2/AVX-512),但牺牲可移植性;
  • PGO:先用真实负载采集 profile,再让编译器按热路径优化,对分支密集的代码通常有效。

Rust 是 AOT 编译,没有 JIT 预热,启动快,适合 CLI、serverless、边缘服务。代价是它不能在运行时根据真实类型和分支 profile 重新 specialize;JIT 在长跑、类型多变的工作负载里仍然有优势。

生态与惯用法:快是写出来的

语言提供上限,代码决定实际值。Rust 里最常见的性能杀手,往往不是“Rust 不够快”,而是把其他语言的写法直接搬了过来:

写法隐藏成本替代
到处 clone()内存拷贝、分配借用、切片、Cow
热路径大量 String分配、UTF-8 校验&str、Cow<str>
Vec<Box<T>>指针跳转、缓存不友好Vec<T>、SoA
热循环里的 Box<dyn Trait>vtable、无法内联泛型、impl Trait
Arc<Mutex<HashMap>>锁竞争分片、DashMap、thread-local
热路径 format!分配、格式化结构化日志、惰性格式化
中间 collect()额外分配迭代器链、一次遍历
无界 channel内存上涨、延迟不可控有界 channel、背压

Rust 的一个文化优势是:成本是显式的。 clone() 要写出来,Box 要写出来,unwrap() 要写出来,分配和可能失败的地方不容易被藏起来。这让性能审查比“看起来没有分配”的语言更容易做。

工具也很成熟:perf、flamegraph、criterion、iai-callgrind、cargo bloat、cargo asm。先测量,再优化;不要凭“Rust 应该很快”下结论。

Rust 不快的场景

把 Rust 当成“自动加速器”一定会失望。下面这些场景,Rust 可能不比 Go/Java 快,甚至更慢:

  • 大量 Rc<RefCell<T>> 组成的图结构,运行时借用检查成为瓶颈;
  • 所有请求共享一个 Arc<Mutex<...>>,核心越多越慢;
  • 热路径上到处 clone(),把零拷贝的优势抵消;
  • 热循环里用 dyn Trait,失去内联;
  • 把 CPU 密集任务塞进 async runtime,阻塞 executor;
  • 复杂控制流里的边界检查无法消除;
  • 泛型过度单态化,二进制膨胀、i-cache 抖动;
  • 编译时间成为团队瓶颈,迭代速度下降。

另一方面,GC 语言在分配密集、生命周期短的负载里可能吞吐更高;C/C++ 在愿意使用 restrict、手写 SIMD、精细控制内存池的场景里仍然可以更快。Rust 的定位不是“绝对最快”,而是在提供内存安全的同时,把性能做到接近手写 C/C++,并且可预测。

把 Rust 写成带 GC 的语言,性能不会自动更好。

一条可操作的性能路径

如果要把“Rust 快”落到项目里,顺序比技巧重要:

步骤做什么判断标准
1 定义指标是吞吐、p99、内存还是启动不同目标对应不同优化
2 测量perf、flamegraph、criterion先找到真正的瓶颈
3 去分配with_capacity、切片、Cow、arena热路径分配次数下降
4 去克隆借用替代 clone,&str 替代 String内存拷贝下降
5 调布局与分派Vec<T>、SoA、泛型/impl Trait缓存命中、内联机会上升
6 调并发分片、线程池、rayon、async 边界锁竞争、尾延迟下降
7 调编译器LTO、codegen-units、target-cpu、PGO构建时间与收益权衡
8 最后才 unsafeget_unchecked、SIMD、FFI有测试、有基准、有回退

顺序不能反。先上 unsafe 再找瓶颈,通常只是把 bug 写进更快的代码里。

结论

Rust 快,不是因为它有一个秘密开关,而是因为它把内存、别名、布局、分派这些性能关键决策放进了类型系统,让编译器在编译期拿到更多信息:

  • 所有权和 Drop 把内存释放变成确定事件;
  • &mut 的唯一性给 LLVM 更强的别名信息;
  • 单态化让泛型、迭代器和闭包通常能编译成手写代码;
  • 布局控制和默认不装箱让缓存更友好;
  • AOT + LLVM 让启动和稳态性能都可控。

代价也同样清楚:学习曲线、编译时间、代码复杂度,以及“你必须理解底层才能拿到最后一截性能”。

所以 Rust 最大的价值不是“最快”,而是性能可预测、成本可见、优化可组合。它不会替你写出快程序,但它会让“为什么慢”更容易被定位。

← 全部文章