Rust 并不安全:你忽略的 6 个崩溃场景
文章目录Rust 并不安全你忽略的 6 个崩溃场景panic!最常见的安全崩溃内存耗尽OOMRust 不帮你兜底栈溢出Stack Overflow递归的隐藏炸弹并发死锁安全但不正确unsafe你亲手关掉了安全依赖库问题你写的是安全代码但程序还是崩总结Rust 并不安全你忽略的 6 个崩溃场景很多人第一次接触 Rust 时都会听到一句话Rust 是安全的语言。这让不少开发者误以为只要用 Rust 写代码就能高枕无忧再也不会遇到程序崩溃的问题。但如果你写过一段时间 Rust做过几个实际项目就会发现一个不太符合预期的现实Rust 程序也是会崩的。panic!最常见的安全崩溃Rust 推崇显式错误处理比如用Result类型包裹可能出错的操作逼着你处理每一种异常情况。但有一类错误它不会给你处理的机会而是直接终止程序那就是panic!。看一段最简单的代码你大概率写过letvvec![1,2,3];println!({},v[10]);// 越界直接 panic运行这段代码程序不会返回错误码也不会继续执行而是直接打印一段 panic 信息然后退出。这种崩溃Rust 称之为安全崩溃因为它是 Rust 刻意设计的行为目的是在错误点停止避免错误扩散。实际开发中以下几种情况最容易触发 panicunwrap()最常用也最危险的取值方式当Option为None或Result为Err时直接 panic且无明确错误信息调试难度大。expect()与unwrap()功能类似但会输出自定义错误信息便于调试定位问题比unwrap()更友好实际开发中应优先使用。数组/切片越界用[]访问超出范围的索引比如上面的示例。panic!()手动调用主动触发崩溃通常用于不可能发生的情况比如逻辑断言失败。总的来说这不是“不安全”而是一种更可控的失败方式与其让错误继续扩散导致内存污染、数据错乱等更严重的问题不如直接终止程序保留错误现场方便排查。内存耗尽OOMRust 不帮你兜底很多人误以为Rust 没有 GC所以不会有内存问题但事实是Rust 没有 GC不代表内存是无限的更不代表 Rust 会帮你控制内存使用。看一段看似无有错误的代码letmutvVec::new();loop{v.push(vec![0u8;10_000_000]);// 每次分配10MB内存}这段代码在编译期不会有任何错误Rust 的借用检查器也会放行但运行起来后它会不断向堆内存中分配数据直到耗尽系统所有可用内存最终被操作系统终止也就是我们常说的 OOMOut 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;letaArc::new(Mutex::new(1));// 互斥锁保护的共享数据aletbArc::new(Mutex::new(2));// 互斥锁保护的共享数据bleta1a.clone();letb1b.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{letptr0x12345as*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既享受它的安全保障也能清醒地应对它无法覆盖的场景写出更健壮、更可靠的程序。