资讯动态

Rust所有权系统解析:从内存安全到借用检查器的工程实践

发布时间:2026/10/9 8:38:00 来源:尧图企业网站定制
1. 所有权系统到底在解决什么问题但凡写过几年代码、从 C/C 或者 Java 转过来接触 Rust 的开发者第一次看到所有权这个概念的时候多半都有一种“这东西我好像在哪儿见过但又说不上来”的感觉。我最初接触 Rust 是在一个嵌入式相关的开源项目里看了一下午的官方文档脑子里最大的疑问始终是为什么这门语言非要搞一套这么别扭的规则让我连一个简单的结构体赋值都要想半天后来在项目里真正踩过几次内存相关的坑、又把编译器报错一条条读明白之后我才逐渐理解到所有权系统本质上是在回答编程语言领域一个非常古老的问题内存到底谁来管、什么时候释放、怎么保证安全。C 和 C 的做法是交给开发者自己管理分配了堆内存就得记得释放释放早了会悬垂指针释放晚了会内存泄漏释放两次直接 undefined behavior。这个方案灵活性能也极致但出错成本极高。我身边不少写 C 的同事项目里的很多精力都花在排查一些“看起来完全不可能”的内存问题上最后定位到是一个早就该释放的对象被某条路径还在使用。Java、Go、Python 这些语言选择了另一条路垃圾回收。开发者不用手动管内存了运行时定期扫描、标记、清理不再被引用的对象。代价是停顿、内存占用翻倍、以及实时性不可控。对普通业务系统来说无所谓但对操作系统、数据库内核、游戏引擎、嵌入式固件这类追求极致性能的场景垃圾回收的成本很难接受。Rust 走的是第三条路用编译期的静态分析来确定每一个值的生命周期让程序在编译阶段就能确认“这个值的合法使用范围到哪里结束”一旦超出范围编译器直接拒绝生成代码。这就是所有制的核心价值不需要运行时机制不需要开发者手动释放却同样能避免悬垂引用、重复释放、内存泄漏这些经典问题。换句话说所有权是一套编译器可以验证的、如何安全地共享和传递内存资源的规则。它把内存安全从“运行时检测”提前到了“编译期证明”这就是为什么很多 Rustaceans 喜欢说“如果编译通过了内存安全问题基本就不会出现”。当然规则变严格了开发者的表达自由度就会受限。所以 Rust 又配套设计了借用检查器、生命周期标注、智能指针等一系列机制来平衡“安全”和“灵活”之间的矛盾。后面我会逐个拆开讲清楚。2. 三条所有权规则的底层逻辑2.1 每个值有且只有一个所有者Rust 的第一条核心规则非常直接每一个值在任意时刻都有且只有一个变量作为它的所有者。这句话听起来简单但它带来的影响深刻到几乎重塑了所有代码组织方式。什么叫“所有者”你可以把它理解成“这个值在内存里的那块区域的负责人”。分配在栈上的基本类型如此分配在堆上的String、Vec等类型也是如此。所有者要负责在合适的时候释放这块内存。当一个值的所有者变量离开了其作用域Rust 会自动调用drop来释放内存。这一点和 C 的 RAII 很相似区别在于 Rust 编译器在编译期就把所有者的身份和生命周期分析得清清楚楚不会出现 C 里手动调用 delete 后不小心又访问的情况。我举个例子来说明这个规则的约束力fn main() { let s String::from(hello); // 此时 s 是字符串值的所有者 let t s; // 这里发生了什么s 的所有权被“移动”给了 t // println!({}, s); // 这行一旦取消注释就会编译报错 println!({}, t); }如果从 C 的角度看这不过是一次普通的赋值s 和 t 理论上都指向同一块内存。但在 Rust 里let t s被执行后s 就再也不可以用了。你可能会觉得这不方便但它恰恰避免了 C 中最麻烦的问题之一同一块内存被两个变量同时持有析构时到底由谁释放如果两个变量都能访问同一块内存又都有“释放”的责任那谁先用完谁释放另一种变量已经释放了而另一个还在访问怎么办这些运行时才会暴露的问题Rust 直接用第一条规则在编译期全部堵死。2.2 值在离开作用域时被释放第二条规则可以看作是对第一条的自然延伸当一个值的所有者变量离开作用域时值会被自动释放。上一节的代码里String::from(hello)在堆上分配了内存当main函数执行到结尾时t离开作用域drop 被自动调用堆内存被自动归还。整个过程没有任何手动 free、没有引用计数、没有垃圾回收扫描。这个规则带来的体验上的变化很微妙。记得我之前用 C 语言写链表的时候每次写完一个插入函数都要仔细数一数这个节点现在是谁在持有这个指针是不是要 freefree 了之后还有没有别的指针指着同一块内存心里没底的时候就只能靠 valgrind 之类的工具去跑。Rust 则把所有这种“记忆负担”交给了编译器。有人可能会问如果值被移动了原来的所有者离开作用域时会释放吗不会。因为移动之后原来那个变量在编译器看来已经不是一个“活”的所有者了它没有任何所有权作用域结束时什么都不用做。所以 Rust 的 drop 语义非常清晰谁当前持有所有权谁在离开作用域时负责善后。2.3 所有权的转移比拷贝更加“默认”第三条规则涉及Copy与move的区分。在 Rust 里赋值和传参默认不是“深拷贝”而是“所有权转移”。只有当类型实现了Copytrait 时赋值才会复制值本身。哪些类型实现了Copy所有简单的基本类型整数、浮点数、布尔、字符以及只包含这些类型且没有实现 Drop 的元组和结构体。String、Vec这些需要堆内存管理的类型不实现Copy因为如果赋值就拷贝那堆内存也被迫要深拷贝一份代价太大语义也不符合直觉。这里我在实际教学和带队时经常看到有人混淆。打个比方在书桌上有一叠纸质文件Copy类型相当于“复印了一份带走原件还在桌上”而move类型相当于“原件直接交给你了我这桌上什么都没有了”。复印是昂贵的所以 Rust 默认不给你复印只有你自己明确要求调用.clone()才会复印。let a 42; // i32 是 Copy let b a; // 这里 b 是 a 的“复印件”a 还能继续用 println!({} {}, a, b); // 正常运行 let s1 String::from(rust); let s2 s1; // 这里 s1 的所有权被移动s1 失效 // println!({}, s1); // 报错value borrowed here after move这套规则推出后很多从脚本语言转过来的同学最初都非常不适应毕竟 Python 里b a本质上只是多了一个引用两边都可以随便用。但多写几周后你会发现自己写代码时对“哪个变量还拥有这块数据”“我这个传参会不会让所有权丢失”这些问题变得异常敏感——这其实是好事因为这种敏感帮你提前规避了大量运行时才可能爆出的内存错误。3. 借用与引用在不转移所有权的前提下使用值3.1 为什么需要借用如果每传一次参就把所有权交出去那代码根本没法写。Rust 显然也知道这一点所以提供了“借用”机制你可以通过引用访问一个值但不拥有它。借用用符号表示。借用发生后原所有者仍然持有所有权只是暂时“借”给了别人用。借用结束时所有权也不会变化不需要归还之类的操作。fn get_len(s: String) - usize { // 这里 s 是对 String 的引用不拥有 String s.len() } fn main() { let s String::from(hello); let len get_len(s); // 把 s 借给 get_len println!({}, {}, len, s); // s 仍然可用 }如果把参数改成s: String而不是s: String那么get_len调用后s就被移动进函数内部函数结束时被释放主函数里再也用不了。所以如果你只是想读一个值不想接管它的生命周期那就传递引用。这是 Rust 代码里最常见的模式。借用机制的存在让函数的意图变得更明确看函数签名就知道它对参数是“只读借用”“可变借用”还是“彻底接管”。这是我后来写大型 Rust 项目时特别喜欢的一点不需要依赖命名规范或注释类型系统本身就把文档写好了。3.2 共享借用与可变借用不能同时存在Rust 的借用规则有两条非常硬核不可变借用共享借用可以同时存在多个。大家都只读互不影响随便借。可变借用独占借用同时只能存在一个。因为允许写入时一旦有另一个读者在读就会产生数据竞争。规则的具体表现就是大家经常会看到的那个编译错误cannot borrowxas mutable more than once at a time或者cannot borrowxas immutable because it is also borrowed as mutable。这个规则可以在编译期就消灭一整类数据竞争问题。注意Rust 说的“数据竞争”有严格定义两个或更多线程同时访问同一内存位置其中至少一个是写操作且没有任何同步机制。这种竞争在多线程程序里是万恶之源往往间歇性出现、压测才能复现、排查成本极高。Rust 直接在类型层面把这种可能性封死可以说是这门语言最漂亮的设计之一。let mut data vec![1, 2, 3]; let r1 data; // 只读借用合法 let r2 data; // 又一个只读借用合法 println!({} {}, r1[0], r2[1]); let w1 mut data; // 可变借用此刻前面的只读借用已不再使用 w1.push(4); // let r3 data; // 错误w1 仍然存活时不能再创建只读借用 // println!({}, r3[0]);很多人会困惑为什么有时候代码看起来已经不再使用某个引用了编译器还是报错这里涉及 RUST 2018 版本以后引入的 NLLNon-Lexical Lifetimes非词法生命周期机制。编译器判断一个借用是否存活的依据不是“这个借用变量是否还在作用域内”而是“这个借用最后一次被使用在哪里”。只要借用不再被使用生命周期就结束后面的代码就可以对这个值做其他操作。这个细节在实际编码中影响很大。早期版本的 Rust 是纯粹按词法作用域来仲裁借用生命周期的经常出现“我明明后面不用了编译器还不让我改”的情况。NLL 加入之后大量不必要的报错和 workaround 都消失了。写代码时只需要遵循一条经验法则可变借用和不可变借用的冲突只存在于它们都被实际使用的区间内如果其中一个已经不再被读取就可以释放掉那个借用约束。3.3 借用规则之外的“内部可变性”逃生舱借用规则这么严格现实需求里总有一些场景确实需要在“共享”的前提下修改数据比如多个线程共同维护一个统计计数器。Rust 提供了一些内部可变性工具最常用的是RefCellT和CellT多线程场景下则是MutexT和RwLockT。CellT适用于实现了Copy的类型通过get和set方法直接读写。RefCellT适用于非Copy类型它在运行时维护借用状态borrow_mut时如果发现已经存在不可变借用会直接 panic。这个机制相当于把一部分借用检查从编译期延后到了运行期代价是需要承担运行时开销和 panic 风险。所以我给团队定的规矩是能用普通借用解决的问题绝不碰 RefCellRefCell 只用于纯内部实现不要暴露到 API 边界。4. 借用检查器的分析原理从语法规则到生命周期推导4.1 借用检查器到底在检查什么借用检查器是 rustc 编译器的一部分它的输入是经过语法解析和类型检查之后的中间表示输出要么是“没问题可以继续生成代码”要么是一堆精心设计的错误信息。它的核心逻辑可以概括为对程序中每一个值跟踪它的生命周期和所有与之相关的引用验证所有引用在其使用期限内都不会指向一个已经被释放或者可能被并发修改的内存区域。具体来说借用检查器会做以下几件事确定每个变量、每个引用从创建到最后一次使用的区间也就是“生命周期”。对所有引用建立“借用关系图”记录哪个引用借用了哪个值、是共享还是独占。检查同时间段内是否存在共享借用与独占借用重叠、多个独占借用重叠。检查引用的生命周期是否超出其指向值的生命周期。这些检查全部发生在编译期不需要运行任何代码。所以借用检查器其实不是“运行时检查器”它更像是一个静态分析器或定理证明器的角色。4.2 生命周期标注给编译器提供更精确信息有些复杂情况下借用检查器无法自动推断出某个引用的生命周期是否安全这时就需要开发者手动标注。生命周期标注用a这样的符号表示看起来像是泛型参数但它不是类型而是“区域”的名字。最常见的场景出现在函数签名中fn first_worda(s: a str) - a str { let bytes s.as_bytes(); for (i, item) in bytes.iter().enumerate() { if item b { return s[0..i]; } } s[..] }这里的a表达的意思是返回的引用与参数引用具有相同的生命周期。也就是说返回的引用指向的字符串不会比输入参数活得更久。没有这个标注借用检查器无法知道返回的引用应该跟哪个输入关联。如果不标注编译器会报错提示你“missing lifetime specifier”。第一次遇到这个问题时不少人的反应是“这玩意好烦能不能不写”。实际上绝大多数函数不需要手动标注因为有生命周期省略规则编译器遵循三条默认规则能省就省。只有当返回引用可能来自多个输入参数之一时才需要开发者用标注明确告诉编译器。我在刚学 Rust 时为了理解生命周期标注一直把它想象成“引用在时间维度的作用域”。就像变量有一个空间上的作用域哪个代码块内可以使用引用有一个时间上的作用域从创建到最后使用。标注的作用就是把两个引用的时间区域明确地关联、约束起来。4.3 所有权与生命周期的关系所有权和生命周期是紧密相关的两个概念但又不同。所有权是“值归谁管”生命周期是“引用在哪些区间内合法”。可以这么理解所有权的存在保证了生命周期推导有一个基准点——因为每个值只有一个所有者所有者的作用域结束值就被释放引用必须在此时之前结束使用。如果没有所有权规则只有生命周期标注那编译器依然无法保证安全。因为即使你标注了“这个引用不会超过某个时间点”但值本身可能被多个地方释放编译器没法确认在引用合法区间内值一定还活着。所有权把所有“释放行为”唯一化了生命周期标注才能在此基础上做精确推导。这也是为什么 Rust 的设计文档中常说“所有权是基础借用是上层生命周期是借用规则的表述语言”。三者缺一不可。5. 实操过程从编译错误到代码重构的完整记录这一节我想用一个非常典型的案例把前面讲的原理串起来。这个例子来自我做过的一个数据处理工具需求很简单从一个字符串切片数组里找到第一个包含某个子串的元素并且返回这个元素本身而不是它的拷贝。初版代码是这样的fn find_keyworda(items: a [String], keyword: str) - Optiona str { for item in items { if item.contains(keyword) { return Some(item.as_str()); } } None } fn main() { let items vec![ String::from(apple), String::from(banana), String::from(cherry), ]; let result find_keyword(items, an); match result { Some(s) println!(found: {}, s), None println!(not found), } }这个实现能正常编译。但接下来需求变了调用方不仅要拿到匹配的元素还想知道匹配的是第几个。于是一个常见的“翻车”修改出现了fn find_keyword_with_indexa( items: a VecString, keyword: str, ) - Option(a str, usize) { for (i, item) in items.iter().enumerate() { if item.contains(keyword) { return Some((item.as_str(), i)); } } None }同样可以编译。那么问题在哪里问题在于我为了让as_str的借用生命周期成立把items的类型从[String]改成了VecString。这虽然能跑但函数签名不够通用调用方如果持有的是一个数组而不是Vec就没法传进来了。正确做法是保持一致用切片[String]因为接受切片的函数既能接收Vec传入的切片也能接收数组的切片fn find_keyword_with_indexa( items: a [String], keyword: str, ) - Option(a str, usize) { for (i, item) in items.iter().enumerate() { if item.contains(keyword) { return Some((item.as_str(), i)); } } None }这种小细节看起来不太起眼但在实际项目里非常影响 API 的可用性。[T]可以同时接收来自VecT、数组、其他切片的借用而VecT只能接收 Vec。所以写参数时能接受切片就尽量不要写 Vec 引用这是 Rust 社区普遍认可的最佳实践。另一个更典型的问题出现在尝试修改原集合时。假设我们找到了匹配元素后希望原地把它改成大写fn find_and_uppercase(items: mut VecString, keyword: str) - Optionmut String { for item in items.iter_mut() { if item.contains(keyword) { item.make_ascii_uppercase(); return Some(item); } } None }这个函数看起来没问题但如果你在调用它的同时又想在匹配前后对items做一次只读遍历就会撞上可变借用和共享借用的冲突。比如这样let mut items vec![ String::from(apple), String::from(banana), String::from(cherry), ]; let matched find_and_uppercase(mut items, an); if let Some(m) matched.as_deref() { println!(matched: {}, m); } for item in items { println!(current: {}, item); }在 Rust 2018 及之后的版本中这段代码通常能编译通过因为 NLL 分析发现matched在最后一次使用后就不再存活后续的只读遍历没有和它重叠。但如果我们在println!(matched)之后再插入一行代码、又用到了matched比如把匹配结果存到一个更长的生命周期变量里借用检查器就会立刻报错。这个例子的实践启示是借用冲突往往不是“你写错了某一行”而是“不同操作之间的生命周期重叠”。排查思路不是单纯看哪一行报错而是追问到底哪个借用活到了什么时候哪个借用和它重叠了。我总结了一个排查顺序找到报错信息中提到的两个借用点。画出各自的存活区间从创建到最后一次使用。检查这两个区间是否重叠。如果重叠考虑调整使用顺序尽量让一个借用先结束生命周期再做另一个操作。如果业务逻辑上必须保持重叠再考虑用RefCell、Mutex等内部可变性工具或者重构数据结构。6. 常见问题与排查技巧实录6.1 五个高频编译错误成因速查打交道这么多年我把 Rust 编译错误里与所有权、借用相关的常见报错做成了一个速查表方便排查时对照。报错关键字常见原因典型场景borrow of moved value所有权已被转移仍然尝试使用原变量赋值、传参、放入集合后继续使用cannot borrow as immutable because also borrowed as mutable可变借用与共享借用生命周期重叠一边iter_mut一边itercannot borrow as mutable more than once同时存在两个可变借用同时传mut给多个函数lifetime may not live long enough返回的引用生命周期与参数没有建立关联函数返回入参中某个字段的引用temporary value dropped while borrowed临时值被借用后作用域结束即被释放对临时String调用返回引用的方法这里我想特别展开说一下最后一个temporary value dropped while borrowed它是新手最常遇到的问题之一。举个例子let s String::from(hello); let tmp_ref s.trim().to_string().as_str(); // 错误这行代码里s.trim().to_string()创建了一个临时的String然后.as_str()拿到了它内部字符串的引用并赋值给tmp_ref。问题是这个临时String的作用域在表达式结束时就结束了临时字符串被释放tmp_ref就变成了悬垂引用。Rust 非常明智地拒绝了这段代码。正确的姿势是把临时字符串保存到一个变量中延长它的生命周期let s String::from(hello); let trimmed s.trim().to_string(); let tmp_ref trimmed.as_str();这一类错误本质上是“借用试图超出所有者的生命周期”。理解了这一点不需要背任何规则也能自然地写出正确的代码。6.2 闭包与借用一个容易忽略的坑闭包是 Rust 里一个极其实用的特性但它和借用规则的交互经常让初学者栽跟头。关键点是一个闭包一次性只能捕获一个变量的可变借用或共享借用且闭包会对捕获方式有自己的推断。如果闭包里既用mut改了某个变量又用读了同一个变量编译器会直接报错。更麻烦的是一个闭包如果已经被推断为持有某个变量的可变借用那在整个闭包生命周期内外部都无法再借用那个变量。我在实际项目中遇到过一种很经典的问题在循环里创建闭包闭包试图捕获循环变量。let mut callbacks Vec::new(); for i in 0..10 { callbacks.push(|| println!({}, i)); }这段代码在 Rust 里会报错因为每个闭包都试图借用循环变量i但闭包存活时间比迭代周期更长。解决办法是使用move关键字让闭包接管i的所有权但要注意move闭包会移动捕获的所有变量如果捕获的是一个需要后续继续使用的堆对象就要小心所有权被“吸走”。排查闭包借用问题我有一条额外的经验打印闭包是否为Fn、FnMut、FnOnce。这三种 trait 对应了捕获方式Fn只读捕获FnMut可变捕获FnOnce移动捕获。当你看到一个函数接受闭包参数时先看它的 trait bound就能预判闭包能不能用move、能不能多次调用、能不能修改外部变量。6.3 自引用结构体为什么朴素写法不可能编译通过最后聊一个非常有意思的话题自引用结构体。当一个结构体中的某个字段引用了另一个字段的数据时比如struct SelfReferential { data: String, reference: static str, // 想引用 data 中的一部分 }这种结构体在 Rust 里无法通过朴素写法构造因为构造reference字段时data还没有稳定驻留在内存中。Rust 要求先创建data再创建指向它的引用但结构体初始化是一次性完成的不允许分两步。而且结构体一旦被移动所有指向内部数据的引用都会失效——Rust 移动语义保证安全性而自引用结构体恰恰破坏了这种保证。实际开发中如果有类似需求常见的替代方案有用索引代替引用。不要存指向内部的引用而是存偏移量或下标使用时再动态访问。把数据拆到外面。结构体只存引用参数最终由外部统一管理数据生命周期。使用PinBoxT。把结构体固定在堆上阻止移动然后使用裸指针或者unsafe代码在外部辅助构造。这种做法一般在异步运行时底层实现里才会用到普通业务代码碰到的概率很低。我个人建议99% 的情况下用方案一或方案二。自引用结构体是 Rust 中少数真正需要unsafe才能实现的场景之一普通应用层开发完全没必要往这个方向靠。想明白“每个值有唯一所有者”这一条很多看似奇葩的限制其实都能反推出来——自引用之所以不被允许就是因为“引用”和“所有者”在同一个结构体内编译器无法证明外部移动结构体时引用依然安全。7. 一个实用的思维模型与经验总结说了这么多我觉得对初学者最有帮助的其实是建立一个可操作的思维模型。我的模型叫“持有、借用、转移”三段论一个值在任何时刻都有一个“持有者”持有者负责释放内存。想读一个值但不持有它就用共享借用T。想改一个值但不持有它就用独占借用mut T。想让另一个函数完全接管这个值未来的生命周期就转移所有权。如果确实需要“共享修改”的语义才引入内部可变性工具但务必认识到这是绕开编译器检查的代价。用这个模型去思考问题几乎所有的所有权和借用错误都可以翻译成一个直白的问题“这个值到底归谁管我要的这个引用能活多久”我还想分享一个团队协作方面的经验。在带 Rust 项目团队时我要求代码评审重点关注函数签名的借用意图每个参数是T、mut T还是T是否与函数行为匹配。这比逐行审查函数体有效得多因为一旦签名正确函数体内的所有权关系基本都会被编译器强制约束住不太可能出大乱子。另外遇到借用检查器报错时不要急着用.clone()或者unsafe把问题“绕过去”。先冷静下来把错误信息完整读一遍。Rust 的编译错误信息质量极高它会明确告诉你是哪个值被移动、哪个借用和哪个借用冲突、生命周期从哪里到哪里不匹配。百分之九十的情况下编译器已经帮你定位到了问题根源你要做的只是顺着它的提示调整代码。如果确实需要克隆来打破借用冲突我会额外加一条注释说明“为什么这里无法使用借用”以免日后维护者误会。克隆本身不是罪过但无脑克隆会让性能问题累积起来尤其在热路径代码里每次克隆都是堆分配和拷贝时间长了性能损耗会非常明显。Rust 的所有权和借用检查器初看像一堵高墙把很多在其他语言里“很正常”的写法挡在门外。但实际用它写上一两个月你会发现这堵墙其实是一份契约它用编译期的严格换取了运行时的自由用写代码时的约束换取了排查内存问题时的省心。真正接纳这套规则之后写 Rust 的体验会变得非常顺编译器不再是“总要跟你对着干”的敌人而是一位随身携带、水平极高、从不疲倦的代码评审员。它会告诉你哪里有风险哪里需要调整哪里其实很安全。这就是我对待所有权系统的最终态度别跟它对抗试着理解它为什么要这样设计。一旦理解了你会觉得它甚至比很多“更自由”的语言更让人安心。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑