C 语言里有一类 bug,它的特征是出错的位置和崩溃的位置没有关系。你在某处 free(p),程序继续跑了两毫秒、经过了三十个函数,某个完全无辜的地方读到一块已经被别人重新分配走的内存,返回一个看似合理的数字。没有信号、没有异常、没有栈回溯指向真凶。等它终于崩溃时,现场早已被清理干净。
这类问题的规模不是学术性的。微软安全响应中心在 2019 年公布过一个数字:微软产品十余年间分配的 CVE 中,约 70% 是内存安全问题。Chrome 团队对严重安全 bug 给出过相近的比例。Google 在 2024 年公布的 Android 数据从另一个方向印证了同一件事——Android 中内存安全类漏洞的占比从 2019 年的 76% 降到 2024 年的 24%,绝对数量从 223 个降到不足 50 个,而这段时间里 Google 做的主要事情就是让新代码尽量用内存安全语言写。
传统的解决路径有两条:一条是垃圾回收,让运行时接管释放时机;另一条是不断改进的动态检测工具(ASan、Valgrind),在运行时抓现行。Rust 走了第三条:把"这块内存现在还能不能碰"变成一个类型系统里可判定的问题,在编译期一次性解决,运行时不留任何检查代码。
做这件事的组件叫借用检查器(borrow checker)。这篇文章讲清楚它到底检查什么、为什么这么设计、以及它索取的价格。
破除误解
误解一:unsafe 关闭借用检查器。
不。unsafe 关闭的是另外五件事,而且是精确的五件。Rust 官方教材把它们称作"unsafe 超能力":解引用裸指针、调用 unsafe 函数或方法、访问或修改可变的 static 变量、实现 unsafe trait、读取 union 的字段。除此之外,unsafe 块里的所有代码——包括所有引用的借用规则、生命周期检查、类型检查——照常全额执行。
在 unsafe 块里写 let a = &mut x; let b = &mut x;,编译器依然拒绝你,和外面一模一样。unsafe 不是"别管我",它是一句非常窄的声明:这一段里有若干条编译器无法验证的命题,我已经验证过了。
误解二:借用检查器保证程序不出内存问题。
它保证的范围比"内存问题"窄得多。内存泄漏在 Rust 里是安全的:std::mem::forget 是一个安全函数;两个 Rc 互相指向对方会形成引用环,谁也降不到零,内存永远不还——全程不需要一行 unsafe。死锁是安全的。逻辑上的竞态(两个线程都正确加锁,但业务顺序错了)是安全的。unwrap 在 None 上 panic 是安全的。整数溢出在 release 构建下默认回绕,也是安全的。
借用检查器排除的是未定义行为(undefined behavior)这一类,即"编译器基于语言规范做出的假设被违反,之后程序的一切行为都不再有意义"。泄漏虽然讨厌,但它不破坏任何假设——程序的行为依然是可预测、可调试的。这条界线划得非常刻意:Rust 在 1.0 之前曾因为"析构函数一定会运行"这个假设被泄漏打破而回炉重设计过标准库接口,最终的结论就是把泄漏正式划到安全一侧。
误解三:编译不过说明我的程序有 bug。
经常不是。借用检查器是可靠但不完备的(sound but incomplete):它保证接受的程序一定没有那几类未定义行为,但不保证所有没有未定义行为的程序都能被接受。判断一个任意程序是否会 use-after-free 是不可判定的,所以任何静态检查都必须在"漏放"和"错杀"之间选一边,Rust 选择永不漏放,代价就是必然错杀。
这不是实现缺陷,是数学上被逼出来的结构。下面会看到 2018 年的 NLL 和至今仍在推进的 Polonius,做的都是同一件事——在保持零漏放的前提下,把错杀的范围往回收。
三条规则,和它们各自负责排除的错误
所有权:谁负责释放
Rust 的所有权规则只有三条:
- 每个值有且只有一个所有者(owner);
- 同一时刻只能有一个所有者;
- 所有者离开作用域时,值被丢弃(drop),内存归还。
第三条意味着释放时机由编译器在编译期插入,不需要运行时判断,也不需要程序员写 free。作用域结束的位置是词法上确定的,编译器直接在那里插一条 drop 调用(真正复杂的情况——条件分支中被部分移动的值——由编译器插入的 drop flag 在运行时处理,但这是编译器生成的,不是程序员的负担)。
第二条负责干掉双重释放(double free)。看这段:
let a = String::from("hello");
let b = a; // 所有权从 a 移动(move)到 b
println!("{a}"); // 编译错误:borrow of moved value: `a`
```String 在栈上是三个字(指针、长度、容量),堆上是字节缓冲区。let b = a 在机器层面只是把这三个字复制一份——和 C 里的结构体赋值完全一样,没有任何额外开销。区别在于编译器同时把 a 标记为"已移动",此后任何对 a 的读取都是编译错误,作用域结束时也只有 b 会触发 drop。
C++ 用 std::unique_ptr 表达同一个意图,但被移动后的 unique_ptr 仍然是一个合法可读的对象(值为 nullptr),编译器不阻止你继续用它。Rust 把"移动后不可用"提升成了编译期的类型状态。
借用:别名与可变,二选一
不想转移所有权时,可以借(borrow)一个引用。借用规则只有一条,但它是整套设计的心脏:
在任意一个时刻,对同一块数据,要么存在任意多个不可变引用
&T,要么存在恰好一个可变引用&mut T,二者不可同时存在。
这条规则常被概括为 "aliasing XOR mutability"(别名与可变互斥)。它排除的不只是双重释放,而是一大类更隐蔽的问题。最典型的是迭代器失效:
let mut v = vec![1, 2, 3];
let first = &v[0]; // 不可变借用 v
v.push(4); // 需要可变借用 v —— 编译错误
println!("{first}");
```为什么这段必须被拒绝?因为 Vec 的容量满了之后 push 会重新分配一块更大的缓冲区、把旧数据拷过去、释放旧缓冲区。first 指向的是旧缓冲区里的位置,push 之后它成了悬垂指针。在 C++ 里这段代码(std::vector + 保存的引用)编译得干干净净,然后在容量恰好触发扩容时随机崩溃。
注意这里的关键:错误不在 push 本身,也不在读 first 本身,而在于别名和可变同时存在。借用规则不去猜 push 会不会扩容——它直接砍掉了"一边有人持有引用、一边有人能改结构"这个组合。
同一条规则顺手解决了数据竞争。数据竞争的教科书定义是三要素:两个线程访问同一内存位置、至少一个是写、没有同步。前两个要素合起来就是"别名 + 可变",而这个组合在单线程里就已经被借用规则禁掉了。多线程只需要再补两个标记 trait:
| trait | 含义 | 典型不满足者 |
|---|---|---|
Send | 值可以安全地移动到另一个线程 | Rc<T>(引用计数非原子) |
Sync | &T 可以安全地跨线程共享,等价于 &T: Send | RefCell<T>(内部可变,无同步) |
于是"线程安全"不再是一份要靠文档和纪律维持的约定,而是编译器可以机械检查的类型属性。把一个 Rc 塞进 thread::spawn 的闭包,编译器直接报错说 Rc<T> cannot be sent between threads safely。这是 Rust 那句 "fearless concurrency" 的全部技术内容——它并不是说并发变简单了,而是说编译不过的那部分并发 bug 你再也见不到了。
生命周期:不是"活多久",是"哪一段代码"
生命周期是三条规则里最被误解的一条。'a 这个标注读起来像"这个对象存活的时间",但它在编译器内部不是时间,是程序中的一片区域——控制流图上的一组程序点。
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str 说的不是"x 和 y 活得一样久",而是"返回的那个引用的有效区域,被 x 和 y 各自的有效区域共同约束"。生命周期参数是一组约束,编译器要做的是解一个区域推断问题,找到满足所有约束的最小区域集合。
绝大多数函数不需要写这些标注,因为有三条省略(elision)规则兜底:每个省略的入参生命周期各自成为独立参数;如果只有一个入参生命周期,它赋给所有输出;如果参数里有 &self 或 &mut self,则用 self 的生命周期赋给所有输出。第三条覆盖了方法调用的绝大部分场景,这是日常写 Rust 很少见到 'a 的原因。
借用检查器实际在做什么
理解了规则,还得看机器怎么执行。rustc 的借用检查跑在 MIR(Mid-level IR)上——一个显式的控制流图表示,2016 年前后成为 rustc 的主力中间表示。流程大致是:
- 收集贷款(loan)。每一次
&x或&mut x产生一笔贷款,记录被借的"位置"(place,如x、x.field、*p)和借的种类(共享 / 可变 / 唯一)。 - 推断区域。为每笔贷款关联一个区域变量,根据赋值、函数签名、返回值等处的约束求解,得到"这笔贷款在 CFG 上的哪些程序点是活的"。
- 逐点检查冲突。遍历 MIR 的每一条语句,看这条语句要对某个位置做的事(读、写、移动、drop)是否与该点上任何一笔活着的贷款冲突。
第三步的判定表就是借用规则的机械化版本:
| 该点活着的贷款 | 读 | 写 | 移动 / drop |
|---|---|---|---|
| 无 | ✅ | ✅ | ✅ |
共享贷款 &x | ✅ | ❌ | ❌ |
可变贷款 &mut x | ❌ | ❌ | ❌ |
有一点值得强调:检查的粒度是位置而不是变量。&mut s.a 和 &mut s.b 借的是两个不相交的位置,可以并存;但 &mut s.a 和 &mut s 冲突,因为后者覆盖前者。这个"不相交路径可以分别借"的能力是很多代码能编译通过的原因,也解释了后面要讲的一类失败——一旦借用发生在函数边界之后,编译器就只能看签名,看不到函数内部借了哪个字段。
NLL:一次把错杀范围砍掉一大半的修正
2018 年之前,rustc 的借用检查跑在 AST 上,贷款的存活范围是词法作用域:一个引用从创建那一刻起,一直活到它所在的花括号结束。于是这段完全正确的代码被拒绝:
let mut v = vec![1, 2, 3];
let first = &v[0];
println!("{first}"); // first 最后一次被使用
v.push(4); // 旧借用检查器:错误。first 的借用还没"出作用域"
```first 在 println! 之后再也没被用过,此时那笔贷款对任何人都不可能造成危害,但词法规则看不见"最后一次使用"这件事。程序员的应对是手动加花括号或者提前 drop——纯粹为了迁就编译器而扭曲代码结构。这是当年"和借用检查器搏斗"(fighting the borrow checker)这个说法的最大来源。
NLL(Non-Lexical Lifetimes,非词法生命周期) 换掉了这个前提:贷款的存活范围不再由花括号决定,而是由活跃性分析(liveness)在控制流图上算出来——一笔贷款只在"它可能还会被用到"的那些程序点上是活的。上面这段于是通过了。
这里有个容易被忽略的因果:NLL 必须等 MIR 就位才可能做。 在 AST 上,"从第 3 行到第 7 行这一段区域"根本不是一个可以表达的东西;只有把程序变成显式的控制流图,"一组程序点"才成为一个能被推断、能被求解的对象。NLL 的名字讲的是结果,实际发生的是借用检查器从语法结构搬到了控制流结构上。
Niko Matsakis 在 2017 年的 RFC 2094 里列了四个当时被错杀的典型场景,NLL 解决了其中三个:
| 问题场景 | 内容 | NLL 是否解决 |
|---|---|---|
| #1 引用存进变量 | let s = &mut data[..]; use(s); data.push(..) | ✅ |
| #2 条件控制流 | match map.get_mut(&k) { Some(v)=>.., None=>map.insert(..) } | ✅ |
#4 重新赋值 &mut 变量 | 链表遍历中 list = list.next | ✅ |
| #3 跨函数的条件控制流 | 见下方 | ❌ |
发布节奏是:2018 年 12 月 6 日的 Rust 1.31 随 2018 edition 首次启用 NLL;2015 edition 的老代码进入"迁移模式"(两套检查器都跑,新检查器发现的问题先报 warning);迁移窗口一路收窄,到 2022 年 8 月的 Rust 1.63,旧的 AST 借用检查器被彻底删除,NLL 对所有 edition 默认生效。一次核心语义修正,前后走了将近四年——因为它不能破坏任何已经在跑的代码。
还没解决的那个:问题场景 #3
fn get_or_insert<'r, K: Eq + Hash + Clone, V: Default>(
map: &'r mut HashMap<K, V>, key: K
) -> &'r mut V {
match map.get_mut(&key) {
Some(v) => v, // 这里把借用返回给调用者
None => {
map.insert(key.clone(), V::default()); // 错误:map 仍被借用
map.get_mut(&key).unwrap()
}
}
}
```map.get_mut(&key) 产生的贷款,因为可能沿 Some 分支被返回出去,它的区域必须包含 'r(整个函数对调用者可见的那一段)。而 rustc 当前的区域推断在传播阶段是位置无关的(用基于强连通分量的图算法,丢掉了程序点信息):一旦某个区域被要求包含"返回"这个事件,它就等于包含函数里的所有点,包括 None 分支。于是编译器认为贷款在 None 分支里也活着,而事实上走到 None 分支就意味着 get_mut 返回了 None,那笔贷款根本不携带任何指针。
Polonius 就是为这件事重写的借用检查器公式化方案,最初用 Datalog 表达(因此得名——莎士比亚里那位爱下定义的老臣),核心变化是恢复位置敏感性:不再问"这笔贷款的区域是什么",而是问"在这个程序点上,哪些贷款可以经由控制流到达"。截至 2026 年,位置敏感的 Polonius alpha 分析已在 nightly 上以功能原型形式存在、通过了性能与兼容性测试,团队的目标是先稳定这个能解决问题场景 #3 及"过滤式借出迭代器"(next() 在循环里重借 self)的子集,完整的流敏感分析(如带条件重借的链表遍历)继续推后。
这条时间线本身说明了一件事:借用检查器的表达力是一个还在缓慢推进的工程,不是一个已完成的定理。 它今天拒绝你的某段正确代码,几年后可能就不拒绝了。
`unsafe`:证明责任的转移,而不是检查的关闭
回到第一个误解。unsafe 的准确语义是:
编译器无法验证这里的某些命题。它们仍然必须为真,只是现在由你来保证。
这是一次证明义务的转移,不是义务的取消。写下 unsafe 相当于在代码里留下一张签了名的欠条。
关键设计不在 unsafe 关键字本身,而在于它可以被封装。Rust 标准库里到处是 unsafe:Vec 的扩容要手工管理未初始化内存;RefCell 的内部可变性绕过了 &T 不可变的规则;Mutex 用裸指针把 &T 变成 &mut T;split_at_mut 从一个 &mut [T] 切出两个不重叠的 &mut [T]——编译器看不出这两半不相交,只能靠人写的裸指针加一句断言。但这些类型对外全都暴露安全接口:只要你按签名用,无论怎么用都不可能触发未定义行为。
于是整个生态形成了一个层次:极少量经过重点审查的 unsafe 代码,托起大量完全安全的代码。这个结构有实测数据支撑。Astrauskas 等人在 OOPSLA 2020 抽样了 crates.io 上三万多个 crate,其中 23.6% 含有 unsafe,76.4% 完全不用;含有 unsafe 的那部分里,unsafe 块通常很小,且大量集中在 FFI 边界。Xu 等人在 TOSEM 2021 检视了截至 2020 年底的全部 Rust 内存安全类 CVE(186 份报告),结论是 Rust 兑现了承诺——所有内存安全 bug 都牵涉 unsafe 代码,纯安全 Rust 没有产生过未定义行为。
代价藏在一个不那么显眼的地方:这张欠条要还给谁,至今没有完全写清楚。 unsafe 代码必须遵守的规则是"编译器基于引用做了哪些别名假设",而这套假设的形式化模型仍在演进中:
- RustBelt(Jung、Jourdan、Krebbers、Dreyer,POPL 2018)第一次给出了 Rust 类型系统的机器化可靠性证明,并给出了判定一段 unsafe 封装是否"可靠"的形式标准。
- Stacked Borrows(Jung 等,POPL 2020)提出了具体的别名操作语义,用一个栈结构跟踪每块内存的合法访问者。它被实现进 Miri——一个执行 MIR 的解释器,能在运行测试时抓出 unsafe 代码里的未定义行为。
- Tree Borrows(Villani、Hostert、Dreyer、Jung,PLDI 2025,获杰出论文奖)把那个栈换成树,在保住编译器优化空间的前提下,比 Stacked Borrows 少拒绝 54% 的测试用例——也就是说,前一个模型判定"违规"的大量真实 unsafe 代码,其实并不需要被判违规。
对写 unsafe 的人来说,这意味着他们在对一份尚未定稿的规范做证明。这不是理论洁癖:一个模型说合法、另一个说非法的代码,在编译器打开更激进的别名优化时行为可能变化。这是 Rust 内存安全叙事里最诚实、也最少被提及的一处松动。
(一个相关的细节:早期 unsafe fn 的函数体自动被视为一个巨大的 unsafe 块,导致里面每一处危险操作都不需要显式标注、审查时无从聚焦。unsafe_op_in_unsafe_fn lint 修正了这个设计,在 2024 edition 中默认开启警告——即使在 unsafe 函数里,危险操作也要自己再套一层 unsafe {}。)
代价一:正确的程序被拒绝,以及逃生舱
不完备是数学上的必然,但落到日常写代码上是具体的摩擦。最常见的一类是编译器只能看函数签名:
impl Foo {
fn get_a(&mut self) -> &mut A { &mut self.a }
fn touch_b(&mut self) { /* 只碰 self.b */ }
}
let a = foo.get_a();
foo.touch_b(); // 错误:foo 已被可变借用
println!("{a:?}");
```直接写 &mut foo.a 和 &mut foo.b 是合法的(不相交位置),但一旦包进方法,签名里只有 &mut self,编译器就必须假设整个 self 被借走。这是抽象边界与精度之间的经典冲突:签名同时是契约和信息屏障,屏障挡住 bug 的同时也挡住了真相。
面对被错杀的正确程序,Rust 提供的逃生舱各有各的账:
| 方案 | 检查搬到哪里 | 出错时表现 | 真实代价 |
|---|---|---|---|
| 重构所有权结构(拆类型、传参而非持有) | 仍在编译期 | 编译错误 | 设计成本,有时得改 API |
索引代替引用(Vec + usize、arena、slotmap) | 不检查 | 取到错的元素 | 别名安全消失,但错的索引不是 UB,只是逻辑 bug |
Cell<T> | 无需检查 | —— | 只能整体读写,拿不到内部引用 |
RefCell<T> / Rc<RefCell<T>> | 运行时 | panic!(already borrowed) | 每次借用一次计数读写;Rc 环会泄漏 |
unsafe + 裸指针 | 人脑 | 未定义行为 | 需要自己写并维护正确性论证 |
注意第二和第四行的一个共同点:它们把"编译期拒绝"换成了"确定性的失败",而不是换回未定义行为。 用索引取到错元素是可调试的;RefCell 借用冲突会在冲突那一刻 panic 并给出栈回溯,而不是两毫秒后在别处崩溃。这才是逃生舱设计的重点——即使放弃静态保证,也要保住"故障点等于出错点"。
代价二:自引用结构为什么这么难
Rust 里的移动(move)永远是一次按位复制,没有 C++ 那样的移动构造函数可以在搬家后修补内部指针。这条规则让移动的开销可预测(永远是 memcpy,编译器可以自由地把它优化掉),但它和自引用结构直接冲突:
struct SelfRef {
data: String,
slice: &??? str, // 想指向自己的 data
}
```第一层障碍是根本写不出来:生命周期参数总是来自外部(函数签名、结构体定义上的 <'a>),没有一个名字能表达"我自己这个结构体实例存活的那段区域"。第二层障碍更根本:就算用裸指针绕过类型系统,把这个结构体从栈上移动到 Vec 里,data 的地址变了,slice 依然指着旧地址。移动是安全操作,任何人都能做,编译器不会阻止。
链表、双向链表、图、带内部游标的解析器,之所以在 Rust 里出名地难写,根源都在这里。Rc<RefCell<Node>>、arena + 索引、unsafe 三条路各有代价,社区教材《Learn Rust With Entirely Too Many Linked Lists》整本书就是在把这笔账摊开算。
真正让这件事变成所有人的问题的,是 async。一个 async 块会被编译成一个状态机结构体:跨越 .await 点存活的局部变量被提升成状态机的字段。如果你在 .await 前后持有对某个局部变量的引用——比如 let buf = [0u8; 1024]; sock.read(&mut buf).await;——那么这个状态机就同时持有 buf 和一个指向 buf 的指针,它是自引用的。
解法是 Pin(Rust 1.33,2019 年 2 月稳定):Pin<P> 是指针的包装,承诺被指向的值此后不再移动。配套的 Unpin 是一个自动 trait,含义是"这个类型移动了也没关系",几乎所有普通类型都自动满足;编译器生成的 async 状态机是 !Unpin,于是真正受到固定保护。这就是 Future::poll 的签名为什么是 fn poll(self: Pin<&mut Self>, cx: &mut Context) -> Poll<Self::Output>——async/await 在 1.39(2019 年 11 月)稳定,整套 Pin 机制是它的地基。
诚实地说这个方案的代价:Pin 的 API 极其难用,手工实现要靠 Pin::new_unchecked、map_unchecked_mut 这些 unsafe 函数,"pin 投影"(从 Pin<&mut Struct> 拿到 Pin<&mut Field>)在实践中基本靠 pin-project 这类宏 crate 完成。async Rust 被抱怨复杂,很大一块复杂度直接来自这里——为了不放弃编译期内存安全,语言必须在类型系统里凭空造出一个"不可移动"的概念,而 Rust 早期的核心假设恰恰是"一切都可以自由移动"。 这是一次事后补丁,补丁的痕迹至今可见。
代价三:学习曲线的真实成因
"Rust 难学"通常被归因于语法或概念数量,但更准确的说法是:借用检查器把一个原本可以推迟的设计决策,强制提前到了写代码的那一刻。
在 C++ 或 Java 里,"这个对象的所有权归谁""谁可以在什么时候改它""这个引用会不会活得比目标久"这些问题可以先不想,代码照样能跑;想错了的代价延后到某次生产事故或某个诡异的并发 bug。在 Rust 里,这些问题必须在代码编译通过之前就有答案,而且答案必须写进类型里。
从工程角度这是一次成本重排:总成本可能没变甚至更低,但成本的分布从"调试期、随机分布、难以归因"移到了"编写期、集中、有明确报错信息"。 这个交易对不同项目的价值差异巨大——一个内核模块或浏览器渲染器愿意付,一个跑三次就扔的数据处理脚本不愿意付。
值得一提的是,Rust 团队把诊断质量当成一等公民来投入。NLL 落地时同时重写了借用检查的错误报告,现在典型的借用错误会明确指出"这里借用发生""这里借用还活着""这里发生冲突"三个位置并给出建议。这不是锦上添花——对一个靠拒绝来工作的系统,错误信息的质量就是它的用户界面。
什么场景下这套设计不划算
- 图与循环引用是问题域核心时。编译器(AST 带父指针)、GUI 组件树、缓存淘汰结构,都会大量落进逃生舱。此时 GC 语言的表达力优势是真实的。
- 原型与探索期代码。所有权结构在需求稳定前反复变,每次变都要重排类型,反馈循环变慢。
- 编译时间敏感的大项目。借用检查本身通常不是瓶颈,但单态化 + LLVM 优化让 Rust 的编译时间对大型代码库是一笔持续开销。
- 依赖了大量 unsafe 的生态位。你的 crate 完全安全,不代表依赖树安全——你实际上继承了所有依赖里 unsafe 作者的证明义务,而你没读过那些证明。
cargo-geiger之类工具能统计依赖树里的 unsafe 分布,但统计不等于审查。 - 需要的保证不在借用检查器的射程内。它不管泄漏、不管死锁、不管 panic、不管逻辑竞态、不管侧信道。把"Rust 写的"当成"安全的"是一次范围错配。
一句话总结这套设计的形状
借用检查器不是一个更聪明的静态分析器——聪明到能看出你的程序不会出错。它是一次问题替换:不去判定"这个程序会不会 use-after-free"(不可判定),而是要求程序满足一个更强、可判定的性质——别名与可变互斥。这个性质蕴含内存安全,而且能在类型系统里检查。
代价是这个替换后的性质严格更强:满足内存安全但不满足它的程序,也一并被拒。Rust 的全部工程史——NLL、Polonius、Pin、RefCell、unsafe 封装、Tree Borrows——都是在这条边界上反复推敲:哪些错杀可以通过更精细的分析收回来,哪些必须靠运行时检查赎回,哪些只能由人签字画押。
跨域连接
- 哥德尔不完备定理:借用检查器的"错杀"不是实现质量问题,而是可计算性给出的硬约束。判定任意程序是否会 use-after-free 属于程序语义的非平凡性质,赖斯定理已经宣告它不可判定;于是任何终止的静态检查都只能在"漏放"与"错杀"之间选一边站。Rust 的选择是把不可判定的原问题替换成一个可判定的、严格更强的充分条件——别名与可变互斥——用错杀率换取零漏放。 这与形式系统里"可靠性优先于完备性"的取舍是同一个动作:宁可证不出某些真命题,也绝不证出假命题。NLL 与 Polonius 的十年推进,本质上是在这个充分条件上做精细化,把它一点点向真实的安全边界逼近,但永远不可能重合。
- 工业工程与质量管理:把内存错误从运行时移到编译期,与丰田生产方式里的"自働化"(发现异常立刻停线)和"防错"(poka-yoke,让错误的装配在物理上做不出来)是同构的。质量管理有一条经验:缺陷的修复成本随着"产生工序"与"检出工序"之间的距离指数增长;C 语言里 use-after-free 的两点距离可以横跨整个产品生命周期,而借用检查器把它压缩成零。 更贴切的类比是防错夹具而非终检——检查器不是事后抽样发现缺陷,而是让不合规的装配根本卡不进去。这也解释了它的副作用:防错夹具必然拒绝一部分本来可行的非标准装配,而抱怨"这个夹具太严"的人和抱怨借用检查器的人,讲的是同一件事。
- 安全工程:
unsafe块的角色对应安全工程里的"受控危险区"。安全关键系统的常规做法从来不是消灭危险,而是把危险收敛进一块边界清晰、经过重点论证、可被独立审查的区域,让区域之外的部分不承担论证义务——这正是最小化可信计算基(TCB)的思路。 Rust 的 23.6% 的 crate 含 unsafe、而所有内存安全 CVE 都落在 unsafe 里这组数据,恰恰是这套围栏在起作用的证据:事故没有消失,但它们全部发生在被标记、被记录、被审查的区域内。与 MISRA C 那类"靠规范文本约束整个代码库"的路线相比,差别在于强制力的来源——一个靠人执行编码规范,一个靠编译器执行且无法绕过。 - 公地治理:crates.io 上一段 unsafe 代码的风险,并不由写它的人独自承担,而是分摊给所有传递依赖它的项目——这是标准的外部性结构,也是公地悲剧的发生条件。奥斯特罗姆对成功公地的观察是它们都发展出了低成本的监测与分级制裁机制,而 Rust 生态的对应物是可见的:
unsafe关键字本身就是一个强制的、可被 grep 的标记,cargo-geiger统计依赖树里的 unsafe 密度,RustSec 咨询数据库承担事后通报。 但监测不等于审查——统计出依赖树里有八百个 unsafe 块,和有人真的读过它们,中间隔着一整个供给缺口。xz 后门事件之后,这个缺口是整个开源供应链共同的未解问题。 - 认知偏误:程序员对别名的直觉是系统性地偏乐观的——"这里不会有别人同时改它"这句判断,在写代码那一刻几乎总是感觉显然为真。这正是可得性与过度自信偏误的典型表现:人评估的是自己此刻能想到的调用路径,而不是全部可能路径,而后者随着代码库规模增长得快得多。 借用检查器的作用是把这个直觉判断变成必须显式书写、且会被机械核验的断言——它不是让你更聪明,而是取消了你"凭感觉认为没事"的权利。所谓"和借用检查器搏斗"的挫败感,绝大多数情况下是两个模型冲突的信号:要么你的心智模型漏掉了一条路径(编译器对了),要么代码的所有权结构本身没想清楚(也是你该改)。真正被错杀的情形远比抱怨声中的比例低。
参考文献
- Matsakis, N. RFC 2094: Non-Lexical Lifetimes. rust-lang/rfcs, 2017. (四个问题场景与基于控制流图的区域推断方案)
- Jung, R., Jourdan, J.-H., Krebbers, R. & Dreyer, D. RustBelt: Securing the Foundations of the Rust Programming Language. POPL, 2018. (Rust 类型系统的机器化可靠性证明,以及 unsafe 封装的正确性判据)
- Jung, R., Dang, H.-H., Kang, J. & Dreyer, D. Stacked Borrows: An Aliasing Model for Rust. POPL, 2020. (unsafe 代码必须遵守的别名操作语义,Miri 的理论基础)
- Villani, N., Hostert, J., Dreyer, D. & Jung, R. Tree Borrows. PLDI, 2025. (用树替代栈的别名模型,比前作少拒绝 54% 的测试用例;PLDI 2025 杰出论文)
- Astrauskas, V., Matheja, C., Poli, F., Müller, P. & Summers, A. J. How Do Programmers Use Unsafe Rust? OOPSLA, 2020. (三万余 crate 的实证:23.6% 含 unsafe,76.4% 完全不用)
- Xu, H., Chen, Z., Sun, M., Zhou, Y. & Lyu, M. R. Memory-Safety Challenge Considered Solved? An In-Depth Study with All Rust CVEs. TOSEM 31(1), 2021. (186 份缺陷报告的分析:所有内存安全 bug 均牵涉 unsafe 代码)
延伸阅读
- Rust 团队. Non-lexical lifetimes (NLL) fully stable. Rust Blog, 2022-08-05. (从 1.31 首发到 1.63 删除旧检查器的四年迁移史,一次核心语义修正如何做到不破坏存量代码)
- Google Security Blog. Eliminating Memory Safety Vulnerabilities at the Source. 2024. (Android 内存安全漏洞占比从 2019 年 76% 降至 2024 年 24% 的数据与归因分析)
- The Rustonomicon. Rust 官方 unsafe 编程手册. (逐条讲 unsafe 代码必须维持的不变量,以及封装为安全接口的判定标准)