Ch 18 · Unsafe Rust —— Rustonomicon 速成
"向编译器承诺以下不变量"
核心问题:unsafe 到底关掉了什么?程序员需要承诺什么?
unsafe 是 Rust 最被误解的关键字。常见误解:"unsafe 关掉了所有安全检查"。真相是:它只关掉一小部分检查,但要求你手动承诺一组不变量。绝大多数 Rust 工程师不应该写 unsafe;但每个 Rust 工程师都应该读懂 unsafe,因为标准库、Tokio、SmallVec、Bytes 等基础库都在用它。
读完你应该能:
- 说出 unsafe 能做的五件事
- 解释 Stacked Borrows 模型简化版
- 列出 10 种 UB,并知道为什么"看起来没事"也可能是 UB
- 用 Miri 验证 unsafe 代码
- 写一个简单的 unsafe 抽象(比如 ring buffer),并把 unsafety 包裹在安全 API 内
18.1 unsafe 能做的五件事
unsafe 在 Rust 里只多给你这五个能力:
- 解引用裸指针
*const T/*mut T - 调用 unsafe 函数(包括 FFI)
- 访问 / 修改可变 static 变量
- 实现 unsafe trait(如
Send,Sync手写) - 访问 union 字段
注意它不给你:
- 关掉借用检查 ——
&和&mut规则仍然成立 - 关掉类型检查 —— 类型仍要匹配
- 跳过 trait bound
unsafe { } 块比初学者想的小得多。
18.2 Aliasing 规则:Stacked Borrows / Tree Borrows
Rust 编译器假设:任一时刻,要么有任意多个 &T,要么有恰好一个 &mut T。这条 alias 规则即便在 unsafe 里也得守。违反 → UB。
Stacked Borrows 模型(简化)
每个内存位置内部有一个"borrow stack"。每次创建引用,push 一个 tag 上栈;访问该位置时,该位置上的 tag 必须在栈中。访问破坏栈结构 → UB。
实战要点:
- 写
&mut x之后,所有先前由 x 派生的&都失效(包括裸指针!) - 裸指针
as *const _/as *mut _时,只有"最近一次创建"的那条链上的指针有效
反例
unsafe {
let mut x = 0u8;
let p = &mut x as *mut u8; // 创建 mutable raw ptr
let q = &mut x as *mut u8; // 再创建一个 —— p 失效
*p = 1; // ❌ UB,虽然编译过
*q = 2;
}
代码看着像无害,但 Stacked Borrows 视角下是 UB,优化器可能据此把 *p = 1 整段删掉。
Tree Borrows
Rust 团队在研发更宽松的模型 Tree Borrows,允许更多看起来合理但 Stacked Borrows 拒绝的模式。实践中目前以 Stacked Borrows 为准(Miri 默认是它)。
18.3 UB(未定义行为)清单
unsafe 代码里你必须避免的事(部分,完整看 Rustonomicon):
- 悬空指针解引用 —— 指向已 drop / 已 free 的位置
- 未对齐访问 —— 解引用 misaligned pointer
- 野指针 / null 解引用(裸指针)
- 违反 aliasing 规则(见上)
- 数据竞争 —— 两线程同时无同步访问同一可变位置
- 读取未初始化内存
- 整数溢出(
unchecked_add等;safe 默认是 wrap 或 panic) - 错误的转换 ——
&u32cast 到&u64然后读取 - 不变量违反 ——
bool里塞 2、enum里塞非法 discriminant、char塞代理对 - panic 跨 FFI 边界
- 错误的生命周期 —— 用
transmute延长生命周期 - 不为 Send 的类型跨线程(手写
unsafe impl Send) - 错误地 mem::transmute —— size/align/语义不对
UB 不是"会崩溃"的同义词——很多 UB 看起来运行良好,直到优化器 / 不同硬件 / 不同 rustc 版本下崩溃。别相信"我跑过没事"。
18.4 UnsafeCell 的真实含义
struct UnsafeCell<T> { value: T }
为什么必须有这个类型?因为 Rust 编译器假定 &T 是 immutable,可以做积极优化(缓存到寄存器、常量折叠)。
UnsafeCell<T> 告诉编译器"这个位置可能被并发修改,不能假设它不变"。唯一能 legally 从 &T 取 *mut T 的类型就是 UnsafeCell<T>。
所有内部可变性(Cell, RefCell, Mutex, AtomicXxx)都基于 UnsafeCell。
18.5 Lifetime variance(回顾 Ch 7)
unsafe 代码里 variance 错误是常见 unsoundness 来源:
struct WrongPtr<'a, T> {
ptr: *mut T,
_marker: PhantomData<&'a T>, // ❌:Should it be &'a mut T?
}
如果你的 WrongPtr<'a, T> 实际上拿"独占可变"语义,但用 PhantomData<&'a T>(协变),编译器允许"短 lifetime 当长 lifetime 用",安全 API 就 unsound。
经验:写带生命周期参数的 unsafe 类型,先停下来画 variance 表。
18.6 写正确的 unsafe 代码的工程纪律
-
把 unsafe 关在最小作用域里
- 不要把整个函数
unsafe fn;unsafe块越短越好
- 不要把整个函数
-
文档化 SAFETY 注释
// SAFETY: ptr 来自 Box::into_raw,刚刚分配,未被 free; // 且 buffer 长度满足 self.cap,所以 offset cap-1 仍在有效范围内。 unsafe { ptr.add(self.cap - 1).write(value) }社区惯例:每个 unsafe 块 / unsafe fn 都要有
SAFETY:注释,说明你为什么相信不变量成立 -
暴露 safe API,unsafe 锁在内部
- 库用户用
Vec::push不该见 unsafe;push内部一堆 unsafe,但push本身是 safe fn,因为内部确保了不变量
- 库用户用
-
测试用 Miri
rustup install nightly rustup +nightly component add miri cargo +nightly miri testMiri 是 Rust 解释器,会检查 UB(stacked borrows、未初始化、悬空指针等)。unsafe 代码的 CI 必跑 Miri
-
多人 review
- 内部规范:unsafe 修改需要至少 2 人 review
18.7 手动实现 Send / Sync
Send / Sync 是 auto trait —— 大部分时候不用你手写。但有些场景需要:
struct MyPool {
inner: *mut PoolImpl, // 裸指针,默认不 Send
}
// SAFETY: PoolImpl 内部用 Mutex 保护所有可变状态,可跨线程安全访问。
unsafe impl Send for MyPool {}
unsafe impl Sync for MyPool {}
危险:你向编译器承诺这个类型是 thread-safe。错了就是 data race,运行时崩。
90% 的情况你不需要手写 Send/Sync——如果你不知道为什么需要,就先别加。
18.8 Miri:Rust 的 UB 检测器
cargo +nightly miri test
Miri 会:
- 检测越界访问
- 检测未初始化读
- 检测 stacked borrows 违规
- 检测 misaligned access
- 检测数据竞争(实验性,加
-Zmiri-num-cpus=N)
代价:Miri 比正常执行慢 ~100x。只跑小规模 unit test,不能上集成测试。
CI 集成示例
- name: Miri
run: |
rustup toolchain install nightly --component miri
cargo +nightly miri test --lib
18.9 案例分析:Vec::push 简化版
pub fn push(&mut self, value: T) {
if self.len == self.cap {
self.grow(); // 重分配,扩大 cap
}
unsafe {
// SAFETY: self.len < self.cap(刚检查过或扩容后必满足);
// self.ptr.add(self.len) 在有效 allocation 范围内;
// 该位置目前是未初始化(超出 len),可以 write 而不 drop 旧值
std::ptr::write(self.ptr.add(self.len), value);
}
self.len += 1;
}
仔细看 SAFETY 注释 —— 它精确描述了三个不变量。任何一条破了都 UB。这是工业级 unsafe 代码的写法。
推荐继续阅读源码
Vec的push/pop/extend_from_sliceBox::new(为什么是 unsafe 内部)Rc的clone/drop(strong / weak count 的原子顺序)Mutex(OS mutex 的封装)
习题
- 不用 Box / Vec 等,只用
allocAPI + 裸指针,实现一个固定大小的RingBuffer<T>。要求暴露 safe API。用 Miri 跑测试。 - 故意构造一个 Stacked Borrows 违规(同时存在两个 raw mut ptr 指向同一位置),用 Miri 测出来。
- 给某个支持自定义 hasher 的 HashMap-like 类型手写
unsafe impl Send,写出 SAFETY 注释。让其他人 review。 - 读
std::cell::Cell的源码,理解为什么它是 safe 抽象但内部全是 unsafe。 - 写一篇 200 字"为什么 unsafe 不该 disable warnings"的内部文档,论证立场。
本章一句话总结
unsafe 不是"关掉安全检查",是"我向编译器承诺以下不变量"。读懂这个差别,unsafe 才能写得正确——大部分人不需要写它,但每个人都应该看得懂它。
📝 学习记录
| 项 | 内容 |
|---|---|
| 起始日期 | |
| 完成日期 | |
| 卡点 | |
| 关键收获 | |
| 配套代码仓库链接 |