读的时候不能写,写的时候不能读:Rust 借用检查器的取舍

Rust 的所有权规定每个值只有一个负责人,借用规则再规定同一时刻要么有多个只读引用、要么只有一个可写引用。编译器把这套规则落实成借用检查:它跟踪每笔借用在哪些程序点仍然活着,一旦与写操作、移动或释放重叠就报错。最常见的误解是把生命周期标注当成延长变量寿命的手段,或者以为移动就是深拷贝、借用检查是运行时开销。本文从 String 的内存布局和 NLL 的活跃区间讲起,解释别名与可变性为什么必须互斥,并给出绕开借用冲突、处理自引用与并发共享的实用做法。

是什么

所有权(ownership)是 Rust 用来管理内存的一组编译期规则:每个值在任意时刻有且只有一个所有者,所有者离开作用域时值被释放,所有权可以移动,但不能被隐式复制。

借用(borrowing)是在不取得所有权的前提下临时访问一个值:&T 是共享借用,允许多个读者同时存在;&mut T 是独占借用,只允许一个,而且它活着的时候连所有者自己也不能读写。

一句话直觉:可以把它想成一套房子的产权与钥匙。产权证只有一本,房主可以配很多把只读钥匙给访客;一旦把装修钥匙交给施工队,其他钥匙必须收回,房主自己也不能再进屋。

典型场景随处可见:函数传参时该给引用还是交出所有权,遍历集合时能不能顺手插入元素,函数能不能返回内部数据的引用,结构体的两个字段能不能互相指向,多线程之间又该怎么共享一份可变状态。这些问题在别的语言里靠约定和文档回答,在 Rust 里由编译器强制回答。

核心原理

先看 let s2 = s1; 到底发生了什么。String 在栈上是一个三元组:指向堆缓冲区的指针、长度、容量;字符数据在堆上。赋值时编译器按位复制这三元组,堆缓冲区一个字节也不复制,随后把 s1 标记为已移动。这不是运行时的标志位,而是编译期的所有权转移:s1 之后不能再被使用,释放责任也转给 s2,所以不会出现两块栈数据指向同一缓冲区、各自释放一次的 double free。实现了 Copy 的类型(i32、bool、&T 等)按位复制后源仍然可用,因为复制出来的值不拥有额外资源;String、Vec、Box 故意不实现 Copy,Clone 才是显式的深拷贝。

借用检查器的工作可以概括成给每笔借用记一份租约(loan):这笔租约在哪些程序点还有可能被使用,就在哪些点活着。两种租约冲突,一是读写重叠或写写重叠,二是租约还活着时所有者被移动、被释放。早期 Rust 按词法作用域判定租约,只要引用还没离开大括号就算活着,常把明显安全的代码判死。Rust 1.31 起,2018 edition 默认启用 NLL(非词法生命周期),判定点从「作用域结束」推进到「最后一次使用」,分析对象则是 MIR 这种带控制流的中间表示。

fn main() {
    let mut v = vec![1, 2, 3];

    let first = &v[0];       // 共享借用开始
    println!("{first}");
    v.push(4);               // 合法:first 的最后一次使用已经过去

    let last = v.last().unwrap();
    // v.push(5);            // 若取消注释:last 之后还要用,租约未结束
    println!("{last}");
}

这段代码能编译,关键在 first 的最后一次使用是 println!,之后租约就结束,v.push(4) 与它不重叠。若把打印挪到 push 之后,或者让 last 在 push 之后还要用,报错就是借用仍活着时出现了可变借用。

这正是别名与可变性互斥(aliasing XOR mutability)的工程价值:只要 &T 存在,编译器就知道这块内存不会被别人改,可以把它缓存进寄存器、省略重复读取;只要 &mut T 存在,编译器就知道没有别的路径能观察中间状态。迭代器失效、use-after-free、数据竞争,本质上都是「有人正在读,另一条路径把它改了或释放了」。Rust 选择在编译期把这种重叠全部找出来,代价是某些合法程序也会被拒。

生命周期参数不创建寿命,也不延长寿命,它只描述引用之间的约束关系。fn longest<'a>(x: &'a str, y: &'a str) -> &'a str 是在告诉调用方:返回的引用不会比两个入参中较短的那个活得更久。省略规则只有几条:只有一个输入生命周期时输出取它;方法带 &self 时输出绑定到 self;其余情况必须显式标注。'static 也常被误解:它表示「可以活到程序结束」,不是「一定活到程序结束」,T: 'static 只意味着类型里不含短命引用,值照样可以在作用域结束时被释放。

借用检查是保守的静态分析:它宁可拒绝一部分正确程序,也不放过一个错误程序。典型例子是按条件返回引用:先 get 再在 None 分支 insert,NLL 会把 get 产生的共享租约沿控制流带到分支汇合处,于是 insert 的可变借用被判冲突;entry API 用一次查找同时完成两件事,正是为了绕开这个限制。Polonius 想把按点活跃改进成按路径、按起源的分析,从而接受这类程序,但它长期停留在实验阶段,默认编译器仍以 NLL 为主。另一个限制是借用粒度:通过方法借 self 时,编译器看到的是整个结构体被借走,哪怕方法只读一个字段;直接写 let a = &mut s.a; let b = &mut s.b; 才能分成两笔不冲突的租约。

需要「共享还能改」时,Rust 把检查从编译期推到运行期:Cell 用复制进出,RefCell 用借用计数,borrow 时计数加一,borrow_mut 时要求计数为零,违反就 panic。它不是绕过了规则,而是把同一套别名约束换了个执行时机。Mutex/RwLock 则把互斥交给操作系统或原子操作,跨线程共享可变状态通常写成 Arc<Mutex<T>>。

常见考点与误区

  • 误区一:生命周期标注能延长变量寿命。 面试常给一段「返回局部变量引用」的代码,问加 'a 能不能修好。不能:标注只表达关系,不改变 drop 顺序,也不能让栈上的局部变量活得比函数调用更久;正确做法是返回所有权、用 Cow,或把数据移到调用方能持有的位置。
  • 误区二:移动就是深拷贝,或者只是复制指针。 语义上移动的是所有权与释放责任,实现上通常只按位复制栈上的头,堆数据不动,还可能被优化掉;说「移动等于 memcpy」不准确,说「移动等于浅拷贝」也不准确,因为源已经失效。
  • 误区三:Rust 没有 GC,所以不会内存泄漏。 所有权防的是 double free、悬垂指针和数据竞争,不防泄漏。Rc 环、mem::forget、Box::leak 都能泄漏,而且泄漏在 Rust 里是安全行为,不是未定义行为。
  • 误区四:借用检查是运行时开销。 检查发生在编译期,运行时没有额外的借用计数(Rc/Arc 与 RefCell 除外)。Rc/Arc 的计数、RefCell 的借用计数是库类型带来的成本,不是借用检查器的成本。
  • 误区五:有了 &mut 只是不能通过别的引用改,原变量还能读。 独占借用的「独占」是对所有访问路径说的:&mut 活着时,所有者既不能读也不能写,更不能被移动或释放。
  • 考点六:Send/Sync 能不能自动保证没有数据竞争? Send 表示所有权可以跨线程转移,Sync 表示 &T 可以跨线程共享,它们由类型结构自动推导;Rc 不是 Send,RefCell 不是 Sync。编译器只保证安全代码里不出现数据竞争,unsafe 里的不变量仍要自己守。

实践建议

  • 先定数据流,再选参数类型。 只读用 &str、&[T]、&T,需要改但不拿走用 &mut T,需要存起来或跨线程转移才取所有权;函数返回借用时先问「数据在谁那里、能活多久」,不要让调用方替你猜。官方《The Rust Programming Language》的所有权章节与生命周期章节把这几条规则讲得最清楚。
  • 冲突时先缩小租约,再考虑 clone。 把借用放进更小的块、先取出索引或长度再调用、用 split_at_mut 拆开不相交的切片、用 iter_mut 代替「边遍历边改集合」;clone 是合法手段,但要清楚它复制了堆数据、增加了一次分配。
  • 别让结构体自引用。 父子互相持有引用几乎必然撞上生命周期;用 ID/索引加 arena(Vec 加下标)、Rc<RefCell<T>>,或干脆把图结构拍平成邻接表。Rc<RefCell<T>> 要在运行时 panic 与循环引用之间做取舍,RefCell 的文档明确写了借用冲突会 panic。
  • 并发共享用 Arc<Mutex<T>>,并缩小临界区。 不要持锁跨 await,标准库的 MutexGuard 不是 Send;能用消息传递(channel)就不要共享内存;写 unsafe 时用 Miri 跑一遍,再用 loom 测并发交错。

一句话总结:所有权回答「谁负责释放」,借用检查回答「此刻谁能读写」;两者合起来,把内存安全变成了一道编译期的别名题。

相关阅读

← 返回文章志