ARTICLE DETAIL

资讯详情

深耕商务建站与企业官网运营的一线实战洞察。

Rust所有权与借用检查:编译期内存管理实践

Rust所有权与借用检查:编译期内存管理实践 写 Rust 这几年被问得最多的一句话就是“所有权到底是个啥”。我一直觉得这个中文译名有点误导听起来像某种法律概念实际上它描述的是一件特别朴素的事一块内存由谁负责释放、什么时候释放、释放之前谁有资格读写。C 里这件事靠人脑记账Java、Go 里这件事交给运行时在后台扫Rust 选了第三条路——把账本写进类型系统让编译器在编译期替你记。代价是你得学会跟编译器讲道理收益是运行时没有停顿、没有悬垂指针、没有双重释放单线程代码里的迭代器失效问题基本绝迹。这篇笔记把所有权、借用检查、生命周期标注、智能指针这几块串起来讲重点放在“它怎么影响内存管理”这个角度而不是复述语法手册。适合已经能写点 Rust 但总被借用检查器拦下来的人也适合从 C 或 Go 转过来、想搞清楚心智模型差异的人。下面所有报错和改法都是我实际踩过的代码可以直接抄去跑。1. 三种内存管理路线Rust 为什么挑了最难的这条1.1 手动管理与垃圾回收各自的账单先把三条路摆在桌面上对比不然后面很多设计取舍会显得莫名其妙。手动管理C、C 的裸指针路线把释放时机完全交给开发者性能上限最高内存布局最可控代价是任何一次疏忽都会变成线上事故而且这类事故往往在压力大的时候才暴露。垃圾回收Java、Go、C# 的默认路线把释放交给运行时开发者写得舒服代价是内存占用曲线偏高延迟不可预测遇到大堆的时候一次回收能带来几十毫秒的抖动。Rust 的所有权系统是第三条路编译器在编译期为每个值推导出唯一的负责方负责方离开作用域就自动释放检查全部发生在编译期运行时不需要任何额外指令。路线释放时机运行时开销典型风险延迟可预测性手动管理开发者调用 free/delete接近零悬垂指针、双重释放、泄漏高垃圾回收运行时扫描判定5% 到 30% 不等看堆大小停顿抖动、内存峰值高低所有权系统作用域结束自动 drop编译期检查运行时为零学习曲线、改写数据结构高这张表里最关键的一列是“运行时开销”。Rust 的所有权检查在编译完成后就消失了生成的机器码里没有任何一条指令是用来“检查所有权”的这叫零成本抽象。你付出的是编译期的思考成本和偶尔的改写成本换来的是运行时的确定性。1.2 所有权本质上是一套编译期推导出来的引用计数我习惯用引用计数来类比所有权这个类比能解释掉八成的疑惑。真正的引用计数比如RcT是在运行时维护一个整数每次克隆加一每次销毁减一减到零就释放。所有权系统做的事情几乎一样区别在于编译器在编译期就能推导出每个值的引用计数永远是 0 或 1既然结果只有两种那就不需要在运行时存这个整数了直接把它编码进类型检查规则里。所以String被赋值给另一个变量之后原变量立刻失效——因为在编译器的账本上这个值的所有者已经换人了旧主人再访问就是越权。这个类比还能解释另一个现象为什么RcT在 Rust 里被归到“智能指针”而不是“所有权”里。因为一旦你真的需要多个所有者编译期就没法推导出唯一负责方了只能退回到运行时记账这就是Rc和Arc存在的原因。理解了这个层次关系你就不会觉得RcRefCellT是一堆奇怪符号的堆砌而会明白它是“编译期账本不够用时的降级方案”。1.3 编译期到底挡住了哪几类事故具体一点所有权加借用检查主要拦四类问题。第一类是释放后使用C 里指针指着的内存已经被free掉读出来是脏数据Rust 里编译器直接告诉你这个值已经 move 走了。第二类是双重释放两个指针指向同一块堆内存析构函数跑两遍堆管理器直接崩Rust 里因为只有一个所有者drop只会执行一次。第三类是迭代器失效这是 C 里最阴的一类 bug往vector里push_back之后原来的迭代器可能指向已释放的旧缓冲区Rust 里你在遍历v的同时调v.push()会直接编译失败。第四类是数据竞争多个线程同时读写同一块内存且没有同步Rust 靠Send和Sync两个标记 trait 在编译期就把不安全的共享挡掉。我印象最深的是第三类。早年写 C 的时候在一个循环里删元素导致迭代器失效程序在小数据量下跑得好好的上线之后数据量上来才开始随机崩溃查了两天才定位到。换成 Rust 之后这种写法连编译都过不去等于把调试成本从线上挪到了敲代码的那几分钟。2. 三条铁律落地move、Copy、Clone 在内存里到底做了什么2.1 三条规则逐条对照内存行为所有权的规则只有三条但每一条都对应着实实在在的内存动作。第一条每个值有且只有一个所有者。第二条同一时刻只能有一个所有者。第三条所有者离开作用域值被丢弃。这三条合起来的效果是任何一块堆内存的释放点都是唯一且确定的编译器在生成代码的时候就知道该在哪里插入释放调用不需要运行时判断。拿String举例。let s1 String::from(hello);这一行会在栈上放一个三元组指针、容量、长度在堆上放五个字节的数据。接着let s2 s1;这一行做的事情是把栈上那 24 个字节复制一份给s2堆上的数据一个字节都不动然后编译器在符号表里把s1标记为失效。如果你这时候写println!({}, s1)编译器报 E0382意思是使用了已经 move 走的值。注意这里没有发生任何堆内存分配或复制move 对String这类类型来说是常数时间操作。2.2 move 不是“搬家”是所有权凭证的转移很多人以为 move 会把堆上的数据搬到新地址这是个误解。move 搬的只是栈上那个描述符堆数据原地不动。这也解释了为什么对大对象做 move 是廉价的VecT装一百万个元素move 一次也只复制 24 个字节的头部。真正昂贵的是clone那才是老老实实把堆数据复制一份。我用一段代码把这个差别量化一下你可以直接跑fn main() { let v1: Vecu64 (0..1_000_000).collect(); let start std::time::Instant::now(); let v2 v1; // move只复制头部 println!(move 耗时: {:?}, start.elapsed()); let start std::time::Instant::now(); let v3 v2.clone(); // clone复制全部堆数据 println!(clone 耗时: {:?}, start.elapsed()); println!({} {}, v3.len(), std::mem::size_of::Vecu64()); }实测下来 move 通常是纳秒级clone 一百万个u64大概在几百微秒到一毫秒之间取决于内存带宽。这个差距在高频路径上是致命的所以我养成一个习惯函数签名里能传引用就传引用确实需要转移所有权才用 move绝不为了图省事到处写.clone()。2.3 Copy 与 Clone 的边界怎么划Copy和Clone经常被混为一谈其实差别很大。Copy是一个标记 trait表示这个类型的值可以按位复制赋值之后原变量依然可用。能实现Copy的类型有两个硬性条件所有字段都是Copy并且这个类型本身没有实现Drop。整数、浮点数、布尔、字符、裸指针、以及由它们组成的固定大小数组和元组都是Copy的。String、VecT、BoxT都不是因为它们管理着堆内存按位复制会导致两个所有者指向同一块堆这正是所有权系统要避免的。Clone则是一个普通 trait需要你显式调用.clone()它允许你自定义复制逻辑比如深拷贝堆数据。任何类型都可以实现Clone包括实现了Drop的类型。这里有个常见的性能坑给一个大结构体derive(Clone)之后在循环里顺手写.clone()传参编译器不会提醒你代码跑起来才发现慢得离谱。我的做法是只有在确实需要独立副本的地方才 deriveClone而且在大结构体上会刻意不 derive逼着自己去想清楚到底该传引用还是转移所有权。顺便说一个实测有用的小技巧用std::mem::size_of快速看一眼类型的栈上开销很多“为什么这个结构体这么大”的疑惑一测就清楚了fn main() { println!(String {}, std::mem::size_of::String()); // 24 println!(str {}, std::mem::size_of::str()); // 16 println!(Boxu64 {}, std::mem::size_of::Boxu64()); // 8 println!(OptionBoxu64 {}, std::mem::size_of::OptionBoxu64()); // 8 println!(Rcu64 {}, std::mem::size_of::Rcu64()); // 8 }OptionBoxT只占 8 字节这件事值得单独说一下。编译器发现Box的指针不可能是零于是用空指针这个非法值来表示None这叫空指针优化。这个优化在链表、树这类递归结构里非常关键不然每个节点都要多带一个字节的判别位再加对齐填充内存占用会明显上升。2.4 函数签名就是一份所有权契约我一直建议新手把函数签名当成一份合同来读。参数写T表示调用方把这个值的所有权交出去了函数返回之后调用方不能再碰它写T表示只借来读函数内部改不了写mut T表示借来改借用期间调用方不能用写self和mut self是同样的道理只是作用在方法接收者上。一份好的签名应该让调用方一眼看出“我还能不能用这个变量”。fn consume(s: String) - usize { s.len() } // 拿走所有权 fn inspect(s: str) - usize { s.len() } // 只借用读 fn append(s: mut String, tail: str) { s.push_str(tail); } // 借用改 fn create() - String { String::from(new) } // 交出所有权这四行基本覆盖了九成场景。我踩过的一个坑是函数参数写成String但其实只读一下长度结果调用方每次都得 clone 一份才能传进去。改成str之后String和字符串字面量都能直接传调用方省掉了所有 clone。这个改动看起来小在一个热路径上能省掉可观的分配次数。3. 借用检查器到底在检查什么3.1 共享不可变、独占可变借用规则只有一句话任意时刻你要么拥有多个不可变引用要么拥有一个可变引用两者不能同时存在。另外所有引用必须始终有效。这条规则的动机有两个层次。浅层是为了防止数据竞争多个线程同时读是安全的只要有写就必须独占。深层是为了防止同一份数据在读取过程中被别处改掉导致读到的内容前后不一致。举一个最典型的例子遍历的同时修改集合fn main() { let mut v vec![1, 2, 3]; for x in v { if *x 2 { v.push(4); // 报错 E0502 } } }编译器会告诉你v已经被不可变借用不能再作为可变借用。这个限制在 C 里是允许的代价是push可能触发重新分配原来的迭代器变成悬垂指针接着就是未定义行为。Rust 直接把这条路堵死。正确的写法是先收集要改的内容循环结束后再统一改fn main() { let mut v vec![1, 2, 3]; let need_push v.iter().any(|x| x 2); if need_push { v.push(4); } }这种“先读后写”的拆分模式我用了无数次几乎所有的借用冲突都能用这个思路化解。它看起来多写了一行实际上是把原本隐藏的时序依赖显式化了代码反而更清楚。3.2 NLL 让借用的结束点变聪明了早期的 Rust 用词法作用域判断借用何时结束只要变量还在作用域里借用就算没结束于是很多写法通不过。从 2018 edition 开始默认启用非词法生命周期NLL借用的有效期缩短到“最后一次使用”为止。这个改进影响巨大很多以前必须用花括号强行划作用域的代码现在可以直接写。fn main() { let mut data vec![1, 2, 3]; let first data[0]; println!({}, first); // first 的最后一次使用 data.push(4); // 现在合法了借用已经结束 println!({:?}, data); }这段代码在老版本会报错因为data的可变借用和first的不可变借用被认为在同一个作用域里共存。NLL 之后编译器看出first在println之后就没用了借用提前结束后面的push就不冲突了。理解这一点很重要它意味着你不需要为了绕开借用检查而写一堆莫名其妙的花括号只要让借用自然地在最后一次使用处结束就行。3.3 生命周期标注不延长寿命只是描述关系生命周期这个词吓退过不少人其实它的作用被严重夸大了。生命周期标注不会让任何值的存活时间变长或变短它只是告诉编译器“这几个引用之间的存活关系是这样的”方便编译器做检查。真正决定值活多久的永远是它所在的作用域。最经典的例子是返回两个字符串切片里较长的那个fn longesta(x: a str, y: a str) - a str { if x.len() y.len() { x } else { y } }不写标注的话编译器报 E0106因为它无法确定返回的引用到底跟着x还是y活。a的意思是返回值的存活时间不会超过x和y中较短的那个。这是一个上界约束不是延长。调用方拿到返回值之后只要保证在x和y都还活着的时候用它就行。生命周期省略规则有三条记住这三条能省掉大部分手写标注。第一每个引用参数各自获得一个独立的生命周期参数。第二如果只有一个输入引用参数那么输出的生命周期全部等于它。第三如果有self或mut self那么输出的生命周期全部等于self的。第三条是方法签名的常用规则所以大多数方法不需要手写生命周期标注。3.4 结构体持有引用必须写生命周期这是新手最容易卡住的地方。结构体里如果放了引用就必须给结构体加生命周期参数因为编译器需要确保这个引用比结构体本身活得久。否则结构体还存在的时候它指向的数据可能已经被释放了。struct Tokena { text: a str, line: usize, } impla Tokena { fn len(self) - usize { self.text.len() } }Tokena读作“这个 Token 的有效期不能超过a”。这意味着任何持有Token的地方那个被引用的字符串也得活着编译器会一路向上检查。这个传播有时候会很烦一个底层结构加了生命周期上面三层都得跟着加。我的处理策略是分场景的。如果这段数据是短命的、临时的、解析完就用完的那就老老实实加生命周期零拷贝最快。如果这段数据要被存起来、跨函数传递、或者放进集合里长期持有那就直接存String用一次分配换掉整条链上的生命周期标注。这个取舍在下一节的实战案例里会详细展开。4. 智能指针编译期账本不够用的时候4.1 Box、Rc、Arc、RefCell 各自解决什么问题所有权的默认模型是单一所有者加独占可变这个模型覆盖了大部分场景但确实有几类需求它表达不了需要在堆上分配且大小在编译期未知的递归类型、需要多个所有者共享同一份数据、需要在只有不可变引用的前提下修改内容。这三种需求分别对应Box、Rc/Arc、Cell/RefCell。类型所有权模型线程安全运行时开销典型场景BoxT独占堆分配取决于 T一次分配递归结构、trait 对象RcT共享非原子计数否每次克隆加减一单线程共享只读数据ArcT共享原子计数是T: SendSync原子操作比 Rc 慢多线程共享CellT内部可变按值替换否几乎为零小 Copy 类型的可变状态RefCellT内部可变运行时借用检查否借用标志位增减需要在 self 里改内容选型的顺序我一般是这样的先问是不是递归类型或需要 trait 对象是就上Box再问是不是真的需要多个所有者如果只是读取共享用Arc包只读数据就够了别急着上RefCell最后才问是不是需要在不可变引用下修改需要才用RefCell。很多人一上来就写RcRefCellT其实八成场景用RcT或者干脆传引用就能解决白白引入了运行时开销和 panic 风险。4.2 Rc 加 RefCell 的代价具体在哪RcT的每次clone都要对引用计数做一次加一每次drop做一次减一减到零才释放。这些操作在单线程里是普通的整数加减开销很小但不为零。RefCellT则维护一个借用计数器borrow()时检查当前有没有可变借用有就 panicborrow_mut()时检查计数器是不是零不是零也 panic。这两个检查都是运行时分支编译器没法优化掉。更需要注意的是循环引用导致的内存泄漏。两个Rc互相指向引用计数永远不会归零内存就漏了。标准解法是把其中一个方向改成Weakuse std::cell::RefCell; use std::rc::{Rc, Weak}; struct Node { value: i32, next: OptionRcRefCellNode, prev: OptionWeakRefCellNode, // 用 Weak 打破环 } fn main() { let a Rc::new(RefCell::new(Node { value: 1, next: None, prev: None })); let b Rc::new(RefCell::new(Node { value: 2, next: None, prev: Some(Rc::downgrade(a)) })); a.borrow_mut().next Some(Rc::clone(b)); println!(a 的强引用数: {}, Rc::strong_count(a)); println!(a 的弱引用数: {}, Rc::weak_count(a)); }Weak不增加强引用计数升级成Rc的时候要调upgrade()返回Option因为目标可能已经被释放了。这个模式在双向链表、树结构里都会用到我建议只要结构里出现“指回去”的边就默认用Weak不要等到内存涨上去才回头改。4.3 有时候该退一步换数据结构而不是堆智能指针这是我踩过的最大的一个坑。早期写一个图结构的处理逻辑习惯性地用RcRefCellNode表示每个节点节点之间互相引用。代码能跑但性能很差而且一旦涉及并发就完全没法用因为Rc根本不是Send。后来改成用VecNode存所有节点节点之间用usize索引互相引用性能提升非常明显代码也短了。struct Graph { nodes: VecNode, } struct Node { edges: Vecusize, // 索引而不是引用 }索引方案的代价是你得自己保证索引有效删节点的时候要处理悬空索引。但它换来的是连续的堆布局、零引用计数开销、天然的Send Sync而且序列化也简单得多。我现在的判断标准是如果一个数据结构里RcRefCell出现超过两处就该停下来想想是不是该用索引或者 arena 了。5. 一个真实场景的重构从编译不过到跑得稳5.1 需求解析文本并缓存字段假设要写一个小模块从多行文本里解析出键值对缓存起来供后续查询。第一版我图省事直接让结构体持有输入的切片struct Configa { entries: Vec(a str, a str), } impla Configa { fn parse(input: a str) - Self { let mut entries Vec::new(); for line in input.lines() { if let Some((k, v)) line.split_once() { entries.push((k.trim(), v.trim())); } } Config { entries } } fn get(self, key: str) - Optiona str { self.entries.iter().find(|(k, _)| *k key).map(|(_, v)| *v) } }这段代码能编译get返回的引用直接指向原始输入零拷贝速度很快。问题出在调用方那边一旦把input的所有权交出去或者input是函数内部临时构造的String就出事了。5.2 第一版在真实调用链里崩了调用方大概是这样的fn load() - Configstatic { let content std::fs::read_to_string(config.txt).unwrap(); Config::parse(content) // 报错返回引用了局部变量 }编译器报 E0515说不能返回指向局部变量的引用。这不是编译器的刁难content在函数结束时就 drop 了返回的Config里那些指针会立刻变成悬垂指针。如果这段代码在 C 里写编译能过运行时读到的就是垃圾数据而且可能好几个月都不出问题直到某次内存布局变化才突然炸。第二版我把结构体改成持有Stringstruct Config { entries: Vec(String, String), } impl Config { fn parse(input: str) - Self { let mut entries Vec::new(); for line in input.lines() { if let Some((k, v)) line.split_once() { entries.push((k.trim().to_string(), v.trim().to_string())); } } Config { entries } } fn get(self, key: str) - Optionstr { self.entries.iter().find(|(k, _)| k key).map(|(_, v)| v.as_str()) } }这样写能跨函数传递、能存进集合、能放进Arc里共享代价是每个键值对两次堆分配。对于配置文件这种一次性加载、长期驻留的场景这个代价完全值得。但如果是解析大文件做流式处理每行两次分配就有点浪费了。5.3 第三版用 Cow 做折中Cow写时克隆是这两者之间的折中方案。它在不需要修改的时候借用原始数据需要修改的时候才分配use std::borrow::Cow; enum Entrya { Borrowed(a str), Owned(String), } fn normalize(s: str) - Cow_, str { if s.contains(\t) { Cow::Owned(s.replace(\t, )) } else { Cow::Borrowed(s) } }Cowa, str是标准库提供的枚举只有Borrowed和Owned两个变体。它的好处是调用方不用关心底层到底是借用还是拥有用.as_ref()或Deref就能当str用。坏处是它仍然带着生命周期参数跨函数边界的时候该传播还得传播而且枚举本身占的栈空间比单纯的str大具体多大可以用size_of实测一下不同版本和优化下会有差异。我现在的选择顺序是数据生命周期短、调用链浅用str加生命周期数据要被长期持有、跨线程传递用String两者之间摇摆不定的中型场景用Cow。这个顺序不是教条但你照着走能少走弯路。6. 高频报错与排查速查6.1 错误码对照表被借用检查器拦住的时候先看错误码再看提示里的“谁的借用还没结束”。下面这张表是我整理的高频错误基本上覆盖了日常八成的情况。错误码含义典型改法E0382使用了已经 move 的值改传引用或在转移前 cloneE0499同一作用域内两次可变借用拆分成两个阶段或用索引代替引用E0502可变借用与不可变借用冲突先读后写或缩短不可变借用的生命周期E0505借用还没结束就要 move让借用先结束再 moveE0515返回了局部变量的引用返回拥有所有权的类型E0597被借用的值活不到那么久调整作用域顺序或改为拥有E0716临时值在借用期间被释放先用 let 绑定成变量再借用E0106缺少生命周期标注按省略规则补上或改成拥有类型E0716 值得单独提一句很多人写过类似的代码然后一脸茫然fn main() { // let s String::from(hello).as_str(); // 报错 E0716 let tmp String::from(hello); // 先绑定成变量 let s tmp.as_str(); // 再借用 println!({}, s); }原因是String::from(hello)产生的临时值在这一行结束时就被 drop 了as_str()借出来的引用立刻悬垂。先绑定到tmp之后tmp活到作用域结束借用才合法。这类问题的排查思路很统一看那个被借用的值是不是临时值是就给它一个名字。6.2 排查顺序先找谁借了谁再看谁要活多久我处理借用错误的固定流程是这样的。第一步看错误提示里点名的变量确认是“值被 move 了”还是“借用冲突”。第二步如果是借用冲突从报错位置往上找找最后一个使用这个引用的地方那里就是借用结束的位置。第三步如果想让借用提前结束就把后面的写操作挪到那之后或者把读的结果先取出来存成独立的值。第四步如果怎么调都不行说明数据结构的所有权模型设计有问题考虑换成索引、Rc或者拥有类型。这个流程里第三步最常用。举一个我经常遇到的场景需要在HashMap里查一个值然后修改它use std::collections::HashMap; fn main() { let mut map: HashMapString, i32 HashMap::new(); map.insert(a.to_string(), 1); // let v map.get(a).unwrap(); // map.insert(b.to_string(), 2); // 报错 E0502 // println!({}, v); let v map.get(a).copied().unwrap_or(0); // 先取值结束借用 map.insert(b.to_string(), 2); println!({}, v); }copied()把Optioni32变成Optioni32借用当场结束后面的插入就自由了。这个技巧在处理哈希表、缓存的时候特别好用我基本养成了习惯只要借出来的值很小且 Copy先拷贝再用。6.3 几条踩出来的实操心得第一别怕加花括号。虽然 NLL 已经聪明很多但在某些复杂表达式里显式划一个作用域让借用提前结束比绞尽脑汁调整顺序省事得多。第二clone()不是洪水猛兽在冷路径上用 clone 换代码清晰度是完全划算的我反对的是在热循环里无意识地 clone。第三遇到RcRefCellT的borrow_mutpanic先找有没有在同一段代码里同时持有两个借用尤其是链式调用里临时值还没释放的情况把中间结果用let绑定出来往往就好了。第四函数签名优先用借用只有在确实需要保存或跨线程的时候才用拥有类型这条规则能让你的模块接口干净很多。第五如果一段逻辑里到处都是unsafe或者反射式的类型转换那多半不是所有权的问题是数据结构选错了回头看看第 4.3 节。关于内存管理我个人在实际操作中最深的一点体会是所有权系统真正改变的不是你写代码的语法而是你设计数据结构的方式。写 C 的时候我会自然地想“这块内存谁 free”写 Java 的时候基本不想写 Rust 之后我会在动手写第一行之前就想清楚“这个值的生命周期有多长、谁能改它、要不要共享”。这个思考前置的过程一开始很别扭习惯之后反而省掉了大量调试时间因为很多问题在敲代码的时候就已经被逼着想明白了。
返回列表
PREV
查看更多资讯
NEXT
返回资讯列表