文章目录
- Rust 并不安全:你忽略的 6 个崩溃场景
- panic!:最常见的安全崩溃
- 内存耗尽(OOM):Rust 不帮你兜底
- 栈溢出(Stack Overflow):递归的隐藏炸弹
- 并发死锁:安全,但不正确
- unsafe:你亲手关掉了安全
- 依赖库问题:你写的是安全代码,但程序还是崩
- 总结
Rust 并不安全:你忽略的 6 个崩溃场景
很多人第一次接触 Rust 时,都会听到一句话:Rust 是安全的语言。这让不少开发者误以为只要用 Rust 写代码,就能高枕无忧,再也不会遇到程序崩溃的问题。
但如果你写过一段时间 Rust,做过几个实际项目,就会发现一个不太符合预期的现实:Rust 程序也是会崩的。
panic!:最常见的安全崩溃
Rust 推崇显式错误处理,比如用Result类型包裹可能出错的操作,逼着你处理每一种异常情况。但有一类错误,它不会给你处理的机会,而是直接终止程序,那就是panic!。
看一段最简单的代码,你大概率写过:
letv=vec![1,2,3];println!("{}",v[10]);// 越界,直接 panic运行这段代码,程序不会返回错误码,也不会继续执行,而是直接打印一段 panic 信息,然后退出。这种崩溃,Rust 称之为安全崩溃,因为它是 Rust 刻意设计的行为,目的是在错误点停止,避免错误扩散。
实际开发中,以下几种情况最容易触发 panic:
unwrap():最常用也最危险的取值方式,当Option为None或Result为Err时,直接 panic,且无明确错误信息,调试难度大。expect():与unwrap()功能类似,但会输出自定义错误信息,便于调试定位问题,比unwrap()更友好,实际开发中应优先使用。- 数组/切片越界:用
[]访问超出范围的索引,比如上面的示例。 panic!()手动调用:主动触发崩溃,通常用于不可能发生的情况,比如逻辑断言失败。
总的来说,这不是“不安全”,而是一种更可控的失败方式,与其让错误继续扩散,导致内存污染、数据错乱等更严重的问题,不如直接终止程序,保留错误现场,方便排查。
内存耗尽(OOM):Rust 不帮你兜底
很多人误以为Rust 没有 GC,所以不会有内存问题,但事实是:Rust 没有 GC,不代表内存是无限的,更不代表 Rust 会帮你控制内存使用。
看一段看似无有错误的代码:
letmutv=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),一旦触发栈溢出,程序会直接崩溃,且无法恢复。
看一段极简的递归代码,也是最容易触发栈溢出的场景:
fnrecurse(){recurse();// 无限递归,不断压栈}fnmain(){recurse();}运行这段代码,结果只有一个:stack overflow,程序崩溃。
这里有一个容易被忽略的点:Rust 不会自动优化尾递归。大多数情况下,即使你的递归是尾递归(递归调用是函数的最后一步),Rust 也不会将其优化为循环,依然会不断压栈,最终导致栈溢出。
实际开发中,更隐蔽的栈溢出场景的是:
- 深层递归 JSON 解析:解析嵌套层级极深的 JSON(比如嵌套几十层、上百层),递归解析会不断压栈,触发栈溢出。
- 树结构遍历:用递归遍历深度极深的树(比如二叉树深度超过1000),同样会导致栈溢出。
- 意外的无限递归:逻辑 bug 导致递归条件永远不满足,触发无限递归。
这类问题在任何语言都有,Rust 也无法豁免。解决办法通常是:将递归改为循环,或尾递归优化,如使用 tailcall 库。
并发死锁:安全,但不正确
Rust 最引以为傲的特性之一,就是零数据竞争(data race),通过所有权机制和Sync/Send特征,在编译期就阻止了数据竞争的可能。但很多人因此误解:用 Rust 写并发代码,就一定是安全的。
事实是:Rust 能保证线程安全,但无法保证逻辑正确。死锁,就是最典型的“安全但不正确”的场景。
usestd::sync::{Arc,Mutex};usestd::thread;leta=Arc::new(Mutex::new(1));// 互斥锁保护的共享数据aletb=Arc::new(Mutex::new(2));// 互斥锁保护的共享数据bleta1=a.clone();letb1=b.clone();// 线程1:先锁a,再锁bthread::spawn(move||{a1.lock().unwrap();// 持有a的锁thread::sleep(std::time::Duration::from_millis(100));// 模拟耗时操作b1.lock().unwrap();// 尝试锁b,此时线程2已持有b的锁});// 线程2:先锁b,再锁athread::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{letptr=0x12345as*consti32;// 手动创建野指针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:既享受它的安全保障,也能清醒地应对它无法覆盖的场景,写出更健壮、更可靠的程序。