Rust 为什么快:从所有权语义到机器码的链路分析
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>> | 空指针 niche | 8 字节 | 指针大小 |
Option<NonZeroU32> | 零值 niche | 4 字节 | 无额外标签 |
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 最后才 unsafe | get_unchecked、SIMD、FFI | 有测试、有基准、有回退 |
顺序不能反。先上 unsafe 再找瓶颈,通常只是把 bug 写进更快的代码里。
结论
Rust 快,不是因为它有一个秘密开关,而是因为它把内存、别名、布局、分派这些性能关键决策放进了类型系统,让编译器在编译期拿到更多信息:
- 所有权和
Drop把内存释放变成确定事件; &mut的唯一性给 LLVM 更强的别名信息;- 单态化让泛型、迭代器和闭包通常能编译成手写代码;
- 布局控制和默认不装箱让缓存更友好;
- AOT + LLVM 让启动和稳态性能都可控。
代价也同样清楚:学习曲线、编译时间、代码复杂度,以及“你必须理解底层才能拿到最后一截性能”。
所以 Rust 最大的价值不是“最快”,而是性能可预测、成本可见、优化可组合。它不会替你写出快程序,但它会让“为什么慢”更容易被定位。