Rust 并不安全:你忽略的 6 个崩溃场景

26 阅读8分钟

Rust 并不安全:你忽略的 6 个崩溃场景

很多人第一次接触 Rust 时,都会听到一句话:Rust 是安全的语言。这让不少开发者误以为只要用 Rust 写代码,就能高枕无忧,再也不会遇到程序崩溃的问题。

但如果你写过一段时间 Rust,做过几个实际项目,就会发现一个不太符合预期的现实:Rust 程序也是会崩的。

panic!:最常见的安全崩溃

Rust 推崇显式错误处理,比如用 Result 类型包裹可能出错的操作,逼着你处理每一种异常情况。但有一类错误,它不会给你处理的机会,而是直接终止程序,那就是 panic!

看一段最简单的代码,你大概率写过:

let v = vec![1, 2, 3];
println!("{}", v[10]); // 越界,直接 panic

运行这段代码,程序不会返回错误码,也不会继续执行,而是直接打印一段 panic 信息,然后退出。这种崩溃,Rust 称之为安全崩溃,因为它是 Rust 刻意设计的行为,目的是在错误点停止,避免错误扩散。

实际开发中,以下几种情况最容易触发 panic:

  • unwrap():最常用也最危险的取值方式,当 OptionNoneResultErr 时,直接 panic,且无明确错误信息,调试难度大。
  • expect():与 unwrap() 功能类似,但会输出自定义错误信息,便于调试定位问题,比 unwrap() 更友好,实际开发中应优先使用。
  • 数组/切片越界:用 [] 访问超出范围的索引,比如上面的示例。
  • panic!() 手动调用:主动触发崩溃,通常用于不可能发生的情况,比如逻辑断言失败。

总的来说,这不是“不安全”,而是一种更可控的失败方式,与其让错误继续扩散,导致内存污染、数据错乱等更严重的问题,不如直接终止程序,保留错误现场,方便排查。

内存耗尽(OOM):Rust 不帮你兜底

很多人误以为Rust 没有 GC,所以不会有内存问题,但事实是:Rust 没有 GC,不代表内存是无限的,更不代表 Rust 会帮你控制内存使用。

看一段看似无有错误的代码:

let mut v = Vec::new();
loop {
    v.push(vec![0u8; 10_000_000]); // 每次分配10MB内存
}

这段代码在编译期不会有任何错误,Rust 的借用检查器也会放行,但运行起来后,它会不断向堆内存中分配数据,直到耗尽系统所有可用内存,最终被操作系统终止,也就是我们常说的 OOM(Out of Memory)崩溃。

关于 OOM,有两个关键点必须明确:

  • Rust 不限制内存分配:Rust 的标准库不会检查内存是否够用,只要你调用分配内存的 API,它就会尝试向操作系统申请内存,申请失败就会崩溃。
  • 标准库默认 OOM 时直接 abort:当内存分配失败时,Rust 标准库的默认行为是直接终止程序(abort),不会进行 unwind,也不会给你清理资源的机会。

更隐蔽的是,实际开发中 OOM 往往不是无限分配导致的,而是内存碎片问题,比如长时间运行的 Web 服务,使用默认分配器时,频繁分配释放不同大小的内存块,会导致内存碎片累积,最终看似有空闲内存,却无法分配出足够大的块,引发 OOM。

一句话总结:Rust 的安全是保障不会出现悬垂指针、double free 等内存错误,但不保证内存够用。内存管理的责任,最终还是落在了开发者身上。

栈溢出(Stack Overflow):递归的隐藏炸弹

栈溢出是所有语言都可能遇到的问题,Rust 也不例外。但由于 Rust 对栈的大小有严格限制(通常是几 MB),一旦触发栈溢出,程序会直接崩溃,且无法恢复。

看一段极简的递归代码,也是最容易触发栈溢出的场景:

fn recurse() {
    recurse(); // 无限递归,不断压栈
}

fn main() {
    recurse();
}

运行这段代码,结果只有一个:stack overflow,程序崩溃

这里有一个容易被忽略的点:Rust 不会自动优化尾递归。大多数情况下,即使你的递归是尾递归(递归调用是函数的最后一步),Rust 也不会将其优化为循环,依然会不断压栈,最终导致栈溢出。

实际开发中,更隐蔽的栈溢出场景的是:

  • 深层递归 JSON 解析:解析嵌套层级极深的 JSON(比如嵌套几十层、上百层),递归解析会不断压栈,触发栈溢出。
  • 树结构遍历:用递归遍历深度极深的树(比如二叉树深度超过1000),同样会导致栈溢出。
  • 意外的无限递归:逻辑 bug 导致递归条件永远不满足,触发无限递归。

这类问题在任何语言都有,Rust 也无法豁免。解决办法通常是:将递归改为循环,或尾递归优化,如使用 tailcall 库。

并发死锁:安全,但不正确

Rust 最引以为傲的特性之一,就是零数据竞争(data race),通过所有权机制和 Sync/Send 特征,在编译期就阻止了数据竞争的可能。但很多人因此误解:用 Rust 写并发代码,就一定是安全的。

事实是:Rust 能保证线程安全,但无法保证逻辑正确。死锁,就是最典型的“安全但不正确”的场景。

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

let a = Arc::new(Mutex::new(1)); // 互斥锁保护的共享数据a
let b = Arc::new(Mutex::new(2)); // 互斥锁保护的共享数据b

let a1 = a.clone();
let b1 = b.clone();

// 线程1:先锁a,再锁b
thread::spawn(move || {
    a1.lock().unwrap(); // 持有a的锁
    thread::sleep(std::time::Duration::from_millis(100)); // 模拟耗时操作
    b1.lock().unwrap(); // 尝试锁b,此时线程2已持有b的锁
});

// 线程2:先锁b,再锁a
thread::spawn(move || {
    b.lock().unwrap(); // 持有b的锁
    thread::sleep(std::time::Duration::from_millis(100)); // 模拟耗时操作
    a.lock().unwrap(); // 尝试锁a,此时线程1已持有a的锁
});

这段代码编译期不会有任何错误,Rust 会确认它没有数据竞争,但运行起来后,两个线程会互相等待对方释放锁:

  • 线程1持有 a 的锁,等待线程2释放 b 的锁;
  • 线程2持有 b 的锁,等待线程1释放 a 的锁;

最终,两个线程陷入无限等待,程序无法继续执行,也就是死锁。

还需要注意的是,Rust 的互斥锁(Mutex)还存在锁中毒问题:如果一个线程持有锁时 panic,会导致锁被标记为“中毒”,后续其他线程尝试获取锁时,会直接返回 Err,若用 unwrap() 取值,就会触发 panic 崩溃。

unsafe:你亲手关掉了安全

Rust 的安全,是默认开启的,只要你不主动使用 unsafe 块,借用检查器就会一直保护你,避免所有未定义行为(UB)。

一旦进入 unsafe 块,Rust 的安全保障就会失效:

unsafe {
    let ptr = 0x12345 as *const i32; // 手动创建野指针
    println!("{}", *ptr); // 解引用野指针,未定义行为(UB)
}

这段代码编译期会通过,但运行时会出现未定义行为,可能崩溃、可能打印乱码、可能损坏内存,一切都是不确定的。

进入 unsafe 块后,你可以自由制造各种不安全的操作:

  • 野指针:手动创建指向无效内存的指针,解引用后会导致内存错误;
  • double free:重复释放同一块内存,导致内存 corruption;
  • 内存越界:手动操作指针,访问超出范围的内存;
  • 突破借用规则:比如同时创建多个可变引用,违反 Rust 的借用规则。

此时的 Rust,和 C++ 几乎没有区别,所有的内存安全,都依赖开发者自己的谨慎。但是要记住 unsafe 不是洪水猛兽,它是 Rust 为高性能、底层开发留下的灵活度,但使用它的代价,就是放弃 Rust 的安全保障,每一行 unsafe 代码,都需要你自己承担所有风险。

依赖库问题:你写的是安全代码,但程序还是崩

即使你严格遵守 Rust 的安全规则,完全不写 unsafe,也无法保证你的程序一定不会崩溃。因为你的程序,依赖了大量第三方 crate。

Rust 的生态和 npm 生态类似:一个项目的依赖链往往非常长,你引入一个 crate,可能会间接引入十几个、几十个依赖。而你的程序的安全性,等于所有依赖的安全性之和。只要有一个依赖出问题,你的程序就可能崩溃。

实际开发中,依赖库导致的崩溃,主要有以下几种情况:

  • 依赖 crate 有 bug:即使是热门 crate,也可能存在逻辑 bug。
  • 版本升级引入问题:依赖 crate 升级后,可能会引入 breaking change,或新增 bug。

更无奈的是,你很难逐一审查所有依赖的代码。一个中等规模的 Rust 项目,依赖链可能有上百个 crate,逐一审查几乎不可能。

记住一句话:你的程序安全性 = 你所有依赖的总和。现实中,很多 Rust 程序的崩溃,都不是来自你自己写的代码,而是来自依赖库的漏洞或 bug。

总结

Rust 是一个强大的工具,但它不是银弹。它能帮你挡住最危险的坑,但无法帮你避开所有坑。理解这一点,你才能真正用好 Rust:既享受它的安全保障,也能清醒地应对它无法覆盖的场景,写出更健壮、更可靠的程序。