Skip to content
Published at:

01. Why Rust — 为什么选择 Rust

一个 C 语言的惊悚故事

在深入 Rust 之前,让我们先看一段看似无害的 C 代码。请问:这段程序会打印什么?

c
#include <stdio.h>

int main() {
    int response;
    int data[4];
    printf("What is the meaning of life? ");
    scanf("%d", &response);
    data[4] = 42;   // 注意:数组越界!!
    printf("You said: %d\n", response);
    return 0;
}

如果你输入 42,你可能期望打印 You said: 42。但在典型的 x86 平台上,这段程序实际输出:

What is the meaning of life? 42
You said: 42

似乎一切正常。等等——data 数组只有 4 个元素(索引 0~3),而 data[4] = 42 明显越界了。为什么程序没有崩溃?为什么 response 的值「恰好」变成了 42?

答案在于编译器的栈内存布局。在典型的 x86 调用约定中,局部变量在栈上按声明顺序的逆序排列(或受对齐和优化影响)。responsedata[4] 碰巧落在了栈上的同一个地址——对 data[4] 的写入悄无声息地覆盖了 response 的值。

而这就是 C 语言的未定义行为(Undefined Behavior,UB):C 标准不规定越界访问的后果,编译器可以做任何事情——静默覆盖、程序崩溃、甚至是格式化硬盘。更可怕的是,如果被覆盖的不是 response 而是函数的返回地址,攻击者就可以劫持程序的控制流——这正是缓冲区溢出攻击的基本原理。

这个问题不是理论上的。1988 年,Morris 蠕虫利用 Unix finger 守护进程的缓冲区溢出漏洞,在几小时内感染了互联网上约 10% 的计算机(当时互联网只有约 6 万台主机)。这场事故直接催生了计算机应急响应小组(CERT)。22 年后的 2010 年,Stuxnet 蠕虫利用 Windows 的多个零日漏洞(其中包括缓冲区溢出)破坏了伊朗核设施的离心机——这是已知的第一个精准攻击物理基础设施的网络武器。

这两起事件跨越 22 年,底层原因完全相同:C/C++ 语言无法在编译期阻止内存错误。

内存安全的代价:三种路线

在 Rust 出现之前,编程语言在内存安全问题上大致有三种路线:

路线一:什么都不管(C / C++)

c
// C 语言:完全信任程序员
char *p = malloc(100);
free(p);
*p = 'x';   // use-after-free,UB,编译器不报错

C 和 C++ 将内存管理的全部责任交给程序员。速度快,没有运行时开销,但代价是:

  • 大项目中几乎不可能保证零内存错误
  • 安全漏洞层出不穷(据 Microsoft 统计,其产品中约 70% 的安全漏洞与内存错误有关)
  • 调试内存错误极端困难——堆损坏和栈破坏的后果可能在距离错误代码千里之外才显现

路线二:垃圾回收(Java / Go / Python / JavaScript)

python
# Python:自动管理内存,安全但不可控
data = [1, 2, 3]
data = None   # GC 最终会回收原列表,但时间不确定

垃圾回收器(Garbage Collector,GC)在运行时追踪所有对象引用,自动回收不再使用的内存。这种方案是安全的——你不可能访问已释放的内存,因为只有 GC 才能释放内存。

但 GC 带来了新的问题:

  • 不可预测的暂停:GC 运行时会暂停整个程序(stop-the-world),对于游戏、实时系统、高频交易等领域不可接受
  • 资源泄漏:GC 管理的是内存,但不会帮你关闭文件、释放锁、断开网络连接
  • 性能开销:GC 需要额外的 CPU 和内存来追踪对象图
  • 缺乏精细控制:你无法精确控制对象何时释放、内存布局如何排列

路线三:Rust 的第三条道路

Rust 选择了与众不同的路线:在编译期通过类型系统保证内存安全,同时不引入运行时的垃圾回收。

rust
// Rust:编译器在编译期拒绝不安全的代码
let mut v = vec![1, 2, 3];
let first = &v[0];   // 不可变借用
v.push(4);           // 编译错误!不能在不释放 first 的情况下可变借用 v
println!("{}", first);

Rust 用**所有权(ownership)借用(borrowing)**两个核心概念,在编译时追踪每一块内存的生命周期。编译器像一位极其严格的审查员,在代码运行之前就能指出所有的内存管理错误。

这一设计的结果是:Rust 程序不会出现悬垂指针、双重释放、use-after-free 等内存错误,同时和 C++ 一样快。

Rust 承担起责任

编译期消灭内存 bug

Rust 编译器承诺了以下安全保证:

内存错误类型C/C++Rust(safe code)
悬垂指针(dangling pointer)UB,编译器不报错编译错误
双重释放(double free)UB,编译器不报错编译错误
Use-after-freeUB,编译器不报错编译错误
空指针解引用UB,编译器不报错使用 Option<T>,编译期强制检查
缓冲区溢出(buffer overflow)UB,编译器不报错运行时 panic(safe code)或编译期保证(迭代器)
数据竞争(data race)UB,编译器不报错编译错误
非法类型转换UB,编译器不报错编译错误或需要 unsafe

注意最后一行:Rust 确实允许你执行任意指针运算和内存操作——但必须写在 unsafe 代码块中。这意味着:

  • 99% 的代码在 safe Rust 中编写,编译器保证无内存错误
  • 1% 的底层代码在 unsafe 中编写,你需要手动审查
  • 当出现内存 bug 时,你可以把排查范围缩小到 unsafe 块中

安全的并发编程

C/C++ 中的并发 bug 是出了名的难调试——数据竞争可能在某些硬件上永远不会触发,却可能在生产环境中导致随机的崩溃和错误结果。Rust 的类型系统将并发安全从运行时提前到了编译期:

rust
use std::thread;
use std::sync::{Mutex, Arc};

// Arc:原子引用计数,允许多线程共享所有权
// Mutex:互斥锁,保证同一时间只有一个线程访问数据
let counter = Arc::new(Mutex::new(0));
let mut handles = vec![];

for _ in 0..10 {
    let counter = Arc::clone(&counter);
    handles.push(thread::spawn(move || {
        let mut num = counter.lock().unwrap();
        *num += 1;
    }));
}

for handle in handles {
    handle.join().unwrap();
}

println!("Result: {}", *counter.lock().unwrap()); // 10

这段代码看似简单,但在 C++ 中达到同样级别的安全保证需要极端的自律和工具支持。在 Rust 中,如果你忘记加锁就访问 Mutex 内的数据,编译器会直接拒绝编译:

rust
// 编译错误:Mutex 中的数据必须通过 lock() 才能访问
let data = Mutex::new(42);
let value = *data;  // 错误:MutexGuard 是一种 RAII 类型,必须显式获取锁

Rust 的 SendSync Trait 进一步从类型层面标记了哪些类型可以安全地跨线程传递或共享。如果你定义的线程间数据传递方式不安全,编译器会在编译期拦截。

Rust 很快

Rust 遵循 零成本抽象(zero-cost abstraction) 原则,这是从 C++ 创始人 Bjarne Stroustrup 那里借来的概念:

你不为不使用的抽象付出代价;你使用的抽象和手写代码一样快。

具体来说:

  • 无垃圾回收停顿:Rust 没有 GC,不需要在程序运行时暂停来打扫内存
  • 无强制装箱:值默认存储在栈上,只有当 Box::new() 显式指定时才分配到堆上
  • 静态分发:泛型和 Trait 默认在编译时生成特化代码(单态化),没有虚表查找开销
  • 内联优化:Rust 基于 LLVM 后端,迭代器、闭包等高阶抽象通常会被完全内联掉
  • 精确的内存布局:你可以控制类型的内存布局(#[repr(C)]),精确匹配 C ABI
rust
// 这两行生成的汇编代码完全相同
let sum: i32 = (0..1000).sum();     // 迭代器,抽象但零开销
let mut sum = 0;
for i in 0..1000 { sum += i; }      // 手写循环

在实际基准测试中,Rust 程序的性能通常与等价的 C/C++ 程序持平(在 ±5% 以内),远快于 Java/Go/C# 等带 GC 的语言(尤其是在内存密集型任务中)。

Rust 让协作更容易

系统编程语言的传统痛点是构建系统和依赖管理。C/C++ 开发者几十年来一直在手动下载依赖、编写 Makefile、处理平台差异。

Rust 从第一天起就配备了 Cargo——集依赖管理、构建系统、测试框架、文档生成、发布工具于一身:

toml
# Cargo.toml —— 声明依赖
[dependencies]
serde = { version = "1.0", features = ["derive"] }
tokio = { version = "1", features = ["full"] }
bash
cargo build    # 下载依赖,编译项目
cargo test     # 运行测试
cargo doc      # 生成 HTML 文档
cargo publish  # 发布到 crates.io

Rust 的 Trait 和泛型系统使得编写通用的、可复用的代码变得容易,而这种代码在 C++ 中通常依赖模板元编程的黑魔法。Rust 的标准库也提供了一套一致的命名和设计约定(例如 From/IntoAsRef/AsMutIterator),意味着不同 crate 之间可以无缝组合。

Rust 的权衡与代价

Rust 不是银弹。选择 Rust 意味着接受以下成本:

陡峭的学习曲线

所有权、借用、生命周期是 Rust 独有的概念,在其他语言中没有直接对应物。大多数 Rust 学习者会在前几周与编译器「搏斗」。借用检查器的错误信息虽然友好(Rust 团队在诊断信息上投入了大量精力),但概念的消化需要时间。

编译速度

Rust 的编译速度慢于 Go、Java 等语言。原因包括:LLVM 后端的优化开销、单态化导致的代码膨胀、Trait 解析的复杂性等。增量编译(cargo check 相比 cargo build)部分缓解了这个问题,但大项目的全量编译仍以分钟计。

语言复杂度

Rust 的类型系统功能丰富——泛型、生命周期标注、Trait 约束、关联类型、GAT(泛型关联类型),学习全部特性需要投入相当的时间。但好消息是,你可以从简单的子集开始,逐步深入。

与现存 C/C++ 代码的交互

虽然 Rust 通过 FFI(外部函数接口)可以调用 C 库,但编写 FFI 绑定需要额外的样板代码。好在有 bindgencxx 等工具可以自动化这一过程。

小结

  • 内存安全是系统编程的核心挑战:Morris 蠕虫(1988)和 Stuxnet(2010)都利用了内存漏洞,根因是 C/C++ 无法在编译期阻止此类错误。
  • Rust 提供了第三条道路:编译期保证内存安全 + 无 GC 运行时开销,在安全和性能之间找到了独特的平衡点。
  • Rust 的安全保证是全面的:涵盖悬垂指针、双重释放、空指针、数据竞争,且通过 unsafe 机制提供了明确的可审计边界。
  • 并发安全内建于类型系统SendSync 和借用规则从编译期杜绝数据竞争。
  • 零成本抽象不是口号:泛型、迭代器、闭包等高级抽象生成的机器码与手写低级代码性能相当。
  • Cargo 生态系统是系统编程的革命:包管理、构建、测试、文档一体化,拥有超过 16 万个 crate。
  • Rust 有代价但不致命:学习曲线陡峭、编译速度较慢、语言本身复杂,但工具链和社区在持续改善。