把所有权理解成「Rust 多出来的语法税」会在生产里立刻失效。借用检查器卡住你,不是风格问题,是你正在引入一条非法别名:同一块内存上同时存在可变写和其它观察者。C++ 把这条规则写在注释和 code review 里;Go 写在 race detector 的抽样里;Rust 写进类型。每个值同一时刻一个所有者、可变别名与其它别名互斥、引用不能比所有者活得长——这三条在编译期排除 use-after-free 和数据竞争。它们不是偏好。
这篇文章只讲生产代码里的所有权:move 如何改变类型状态、借用如何限制别名、生命周期如何命名范围、内部可变性如何把检查推迟到运行时、跨 await 为什么把栈引用变成未定义。读完你应该能独立回答三个问题:这段 API 该不该拿走所有权、跨任务共享该用 Arc 还是该用 channel、为什么这段看起来无害的 clone() 会把热路径打穿。
1. 所有权管的是别名,不是「有没有 malloc」
栈上的 i32 和堆上的 Vec<u8> 都有所有者。区别只在 Drop 时要不要释放堆。把所有权理解成「RAII 的另一种写法」,会漏掉更硬的那一半:同一时刻谁可以观察这块内存。
别名(alias)是:两个路径能碰到重叠的字节。读-读通常无害;读-写或写-写,在没有同步的前提下,对优化器和 CPU 都是未定义或至少不可推理。Rust 的类型系统把「可变 XOR 共享」做成默认。能编过的安全代码,优化器可以假定 &mut T 期间没有其它指针伸进来——这和 C 的 restrict 是同一类承诺,只不过 Rust 强制你兑现。
所以生产里画所有权图,不是画「谁 free」,是画「谁能读、谁能写、写的时候还有没有别人在看」。Box、Vec、String、文件句柄、socket,都只是这张图上的节点。堆只是 Drop 的账单。
一个直观对照:let s = String::from("ok"); let t = s; 之后 s 不能再用,不是因为字节从栈搬到了另一处栈槽那么简单——LLVM 对该 move 经常直接改名。失效的是名字。类型状态从「这个绑定拥有 String」变成「这个绑定已失效」。你再写 s.len(),碰到的是一个不存在的所有者,不是一块还在但「被借走」的内存。
2. 三条不变量,对应三类事故
把规则写成事故语言,比背教科书有用:
- 单一所有者。对应 double-free、忘记 free、在已经 move 走的值上再跑析构。
Vec的缓冲区指针只该出现在一个Vec头里;第二个头如果也认为自己该dealloc,进程就在分配器里炸掉。 - 可变与共享互斥。对应数据竞争、迭代时修改容器、把
&T当成可以偷偷改。迭代器拿着指向元素的指针时,push可能重分配,指针变悬空——所以for x in &v { v.push(1); }直接拒绝。 - 借用不长于所有者。对应 use-after-free、返回指向局部的指针、把引用存进结构体却把结构体活得比数据源更长。
fn take(buf: Vec<u8>) { /* 所有权移入,调用方名字失效 */ }
fn peek(buf: &[u8]) -> usize {
buf.len()
}
fn append(buf: &mut Vec<u8>, b: u8) {
buf.push(b);
}
fn demo() {
let mut buf = vec![1, 2, 3];
let n = peek(&buf);
append(&mut buf, 4);
take(buf);
// peek(&buf); // 所有者已走,再借是无意义的名字
let _ = n;
}
这三段函数签名就是三种契约。take 说「我之后负责 drop」;peek 说「我只看,立刻还」;append 说「这段时间只有我能写」。生产 API 的第一问不是「要不要用泛型」,是「这三种里哪一种」。选错的症状很具体:要么调用方被逼 clone,要么你在结构体里存了 &[u8] 却发现请求结束缓冲已经还回连接池。
3. Move:类型状态被转走,不是口号里的 memcpy
Copy 类型(整数、共享引用、不含析构的小结构)赋值是按位复制,两边都能用。非 Copy 赋值是 move:源绑定进入「已移动」状态,析构只会发生在目的地。Vec、String、File、几乎所有带资源的类型都是这条路。
部分移动更容易在生产里踩:
struct Conn {
addr: String,
fd: std::fs::File,
}
fn steal_addr(c: Conn) -> String {
c.addr
// c.fd 在函数结束时 drop;c 作为整体不能再被使用
}
从 Conn 里拿出 addr 之后,剩余字段仍要按 Drop 跑完。你不能既把 addr 还回去,又假装 Conn 完整。这就是为什么「拆包再拼回去」经常要 mem::take / Option::take:用 None 占住那个槽,让结构体在类型上仍然完整,值上已经把资源让出去。
Drop 的顺序是:先跑你写的 Drop::drop,再按字段声明的逆序析构。结构体里先声明 buf: Vec<u8> 再声明 mmap: Mmap,析构时 mmap 先走、buf 后走。依赖「另一个字段还活着」的 drop 逻辑,必须把这个依赖写成类型(包进同一个字段、或用 ManuallyDrop 自己调),不能靠声明顺序的运气。panic 展开同样会跑 drop。catch_unwind 能拦住 panic,拦不住「半初始化结构体」——这就是为什么 Vec::push 的实现要先写完槽再改 len:中途 panic 时 len 仍覆盖已初始化的前缀,析构才安全。生产里自己写缓冲或对象池,先把这条 panic safety 画出来,再写 unsafe。
mem::take(&mut x) 留下 Default;mem::replace 留下你指定的值;Option::take 留下 None。网关里把请求体从 Option<Bytes> 拿走、把连接从 Option<TcpStream> 拿走,都是同一手法。不要 clone 只为了让编译器闭嘴——那是在用分配换一个你还没想清楚的所有权边界。
Move 跨线程是 Send 的含义:这个值可以换一只手拿。不是「可以同时被两只手拿」。后者是 Sync,下一节借用和再下一节 Arc 会碰到。
4. 借用:共享与独占是同一条别名规则的两端
&T 可以有很多个,只要期间没有 &mut T。&mut T 只能有一个,且期间不能有 &T。NLL(non-lexical lifetimes)让借用在最后一次使用处结束,而不是在作用域的花括号处结束,所以下面能编过:
fn nll_ok(v: &mut Vec<u8>) {
let first = v.get(0).copied();
v.push(9); // first 已经不再借用 v
let _ = first;
}
编译器做的是:看每次借用覆盖哪些路径、覆盖到哪一次使用。重叠且冲突就报错。它不是在模拟你的运行时,更不会「大概觉得没问题就放行」。
结构体字段可以分裂借用:&mut s.a 和 &mut s.b 同时存在是合法的,因为路径不重叠。这直接决定了你怎么拆状态。一个巨大的 State { conn, buf, stats },三个字段总是一起 &mut,调用方会互相卡住。拆成 conn 自己的结构、stats 用原子或独立锁,借用冲突会少一个数量级。借用检查器在惩罚过大的可变表面。 这是设计反馈,不是噪音。
再借用(reborrow)是 &mut 能传进函数的原因:调用 f(&mut *x) 时,外层 &mut 被临时冻结,内层借出去,回来再解冻。你写 f(&mut x) 时几乎总在发生再借用。理解它,才能读懂「为什么我把 &mut self 传进去,回来还能继续用 self」。
切片和迭代器是借用规则的日常形态。&v[i] 借的是元素;v.iter_mut() 借的是每一个元素的独占,但通过迭代器协议保证不同 next() 不重叠。你自己用裸索引写两个 &mut v[i] 和 &mut v[j],编译器不信 i != j——它没有值域分析来给这条别名开例外。要同时可变地碰两个元素,用 split_at_mut:它在库里用 unsafe 证明两半不重叠,再把安全的两个 &mut [T] 交给你。
5. 生命周期:给引用的有效范围起名
'a 不是对象,不是 GC 根,不是运行时时钟。它是编译器用来比较「这段借用最多能活到哪」的名字。fn longest<'a>(x: &'a str, y: &'a str) -> &'a str 说的是:返回值的有效范围不长于 x 和 y 中较短的那个。调用方拿到的 &str 不能在 x 或 y 失效之后还用。
省略规则(elision)只覆盖常见形状:输入引用的生命周期能唯一地推到输出上。结构体里存引用,省略帮不上忙,必须写出来:
struct View<'a> {
head: &'a [u8],
payload: &'a [u8],
}
fn split_header(frame: &[u8]) -> Option<View<'_>> {
if frame.len() < 4 {
return None;
}
Some(View {
head: &frame[..4],
payload: &frame[4..],
})
}
View<'a> 把「这两片都指向 frame 的缓冲」写成类型。frame 一还回连接池,View 就不能再存在。这就是为什么协议解析的热路径要么当场用完 View,要么把需要留下的字节变成 Bytes(引用计数的拥有切片,见零拷贝那篇)。
生命周期参数出现在错误信息里时,先不要改标注来「骗过」。问:这个引用指向的数据,活在哪个所有者上?那个所有者会不会在引用还在时被 drop、被 move、被重新填充?答不上来,改标注只是把报错挪到另一个调用点。
高阶生命周期(for<'a>)会在回调和 Fn trait 上冒出来:fn(&str) -> &str 其实是「对任意短生命周期都能成立」,不是「存在某一个 'a」。闭包若把输入引用存到自己的环境里,就不再满足 for<'a>。生产里这常表现为「我把闭包传给 tokio 的 spawn 就不行」——环境里藏了指向栈上缓冲的指针。
生命周期还有方差:&'a T 对 'a 协变——短的可以当长的用吗?反过来。&'a T 可以从「活得更长」的引用缩短成「活得更短」的引用,所以 &'static str 能传给要 &'a str 的函数。&'a mut T 对 'a 协变、对 T 不变。不变的直观原因:若 &mut Vec<&'static str> 能当成 &mut Vec<&'a str>,你就能塞一个短生命周期的 &'a str 进去,原持有者再按 'static 读,悬空。报错看起来像生命周期,根因是你在可变别名上做了不安全的缩短。不必背完方差表,但看到 mut 和生命周期缠在一起时,先问「我是不是把短的写进了长的容器」。
6. 内部可变性:把别名检查从编译期挪到运行时
有些共享是算法需要的:缓存、一次写入的配置、引用计数内部的计数器。类型系统给了一条受控的后门:外层是 &T,内层在运行时检查或用原子保证互斥。
Cell<T>:只适用于Copy或通过replace整颗换掉。没有别名出去的&T指向内部,所以可以在&Cell<T>上改。单线程、小状态。RefCell<T>:运行时借计数。borrow_mut时若已有借出会 panic。单线程图结构、测试替身常用。生产热路径上 panic 不是错误处理,是你的别名假设写错了。Mutex<T>/RwLock<T>:跨线程的互斥。锁本身是Sync,MutexGuard不是Send(std的守卫跨await会把运行时卡住,见调度和争用两篇)。UnsafeCell<T>:上面那些的底层。自己写等于自己证明别名。
use std::cell::RefCell;
struct Cache {
hits: RefCell<u64>,
}
impl Cache {
fn bump(&self) {
*self.hits.borrow_mut() += 1;
}
}
bump 拿 &self 却改了计数。类型上这是共享;事实上同一时刻只有一个 borrow_mut。单线程里这是便宜的。多线程里 RefCell 不是 Sync,你会被逼换成原子或锁——这正是类型系统把「内部可变性的半径」画出来。
OnceCell / OnceLock / std::sync::OnceLock 解决「先写一次,以后只读」。配置、正则、全局客户端走这条,不要用 Mutex 包一个写完再也不改的值。
循环引用是 Rc/Arc 的经典坑:两个节点互相持有强引用,计数永远到不了零。Weak 是「观察但不负责存活」。图、观察者列表、缓存回指,该用 Weak 的地方用强引用,就是泄漏。泄漏不是内存不安全,但在生产里一样能把 RSS 拉成一条斜线。
原子也是内部可变性:AtomicU64 的 load/store/fetch_add 都收 &self。它不提供 &mut 指向那个整数,所以别名规则在类型上仍成立;互斥由 CPU 的原子指令保证。适合计数、开关、epoch、状态机的小枚举。不适合「改完这个字段还要改那个字段,两者要一起被看到」——那是锁或单任务所有权的活。Relaxed 只保证这个位置的原子性,不保证和其它内存的顺序;跨线程发布一个指针必须配 Release/Acquire 或更强。生产里把所有原子都写成 SeqCst 能先换正确性,测出热再收紧。不要在没测的情况下用 Relaxed 发布配置指针。
7. Send / Sync:跨线程的别名许可
Send:值可以 move 到别的线程。Sync:&T 可以送到别的线程,等价于 T 可以同时被多线程只读(或内部自己同步)。
推论:
Arc<T>: Send当且仅当T: Send + Sync(简化记忆:内层既要能在别的线程 drop,也要能同时读)。Rc<T>不是Send,也不是Sync。单线程共享所有权用它,跨任务不要碰。RefCell<T>不是Sync。Mutex<T>是。&mut T是Send如果T是Send——独占引用换线程仍然独占。- 裸指针既不是
Send也不是Sync,除非你自己unsafe impl。
tokio::spawn 要求 Future + Send + 'static。报错 *mut u8 cannot be sent 或 RefCell cannot be shared,不是 spawn 的毛病,是你把单线程别名模型带进了多线程调度器。修法是:改成 Arc<Mutex<_>>、改成每个任务自己的状态、或者用 spawn_local 并证明这个 runtime 永不把任务挪走。
'static 在 spawn 里的意思常常被读成「这个值要活到进程结束」。它真正说的是:future 不从调用栈上借东西。任务可能在调用 spawn 的栈帧已经没了之后才跑完,引用会悬空。拥有数据(String、Bytes、Arc)就满足 'static,哪怕这个 Arc 下一秒就被 drop——'static 约束的是借用,不是「对象永生」。
Send 漏掉的典型现场是 async 块里捕获了 &mut 某个 !Send 的东西,或者用了 std::rc::Rc 当缓存。错误信息会指向 spawn 那一行,真正的捕获在几十行之外。把 async 块改成显式 async move,把捕获的变量在块前列出来,not Send 会立刻指出是哪一个。tokio::spawn 的约束写在签名上:F: Future + Send + 'static。你无法用注释说服它。想跑 !Send 就 LocalSet + spawn_local,并接受这些任务钉在一条线程上。
另一个生产细节:MutexGuard、RwLockReadGuard 通常是 !Send。持着 std 锁跨 await,多线程 runtime 会拒绝(守卫可能在另一条线程上解锁,而 std::sync::Mutex 要求谁锁谁解)。这看起来像生命周期或 Send 的玄学,其实是在保护操作系统 mutex 的契约。修法不是 unsafe impl Send,是把锁的范围缩回同步代码。
8. 跨 await:Future 被放到堆上之后
async fn 被编译成状态机。每个 .await 是一个可能暂停的点。暂停时,仍然活着的局部变量会成为状态机的字段。这个状态机会被 spawn 放到堆上,可能在另一条 worker 上被唤醒。
所以:async fn 里对栈上 Vec 的 &mut 跨过 await,等于把指向「可能已被调度走、甚至已被 drop 的栈槽」的指针存进堆上的 future。借用检查器拒绝,是因为它无法证明唤醒时那块栈还在。
async fn wrong(buf: &mut Vec<u8>) {
buf.extend_from_slice(&[1, 2, 3]);
tokio::time::sleep(std::time::Duration::from_millis(1)).await;
buf.push(4); // buf 的借用跨过 await
}
async fn right(mut buf: Vec<u8>) -> Vec<u8> {
buf.extend_from_slice(&[1, 2, 3]);
tokio::time::sleep(std::time::Duration::from_millis(1)).await;
buf.push(4);
buf
}
wrong 的签名把调用方的缓冲借进一段不确定何时结束的未来。连接池、请求作用域、取消,都会让这个未来比缓冲活得更长。right 把所有权带进任务,结束时还回去——契约在类型上闭合。
生产里更常见的形状是:同步代码里把 &[u8] 解析完,把需要留下的部分 Bytes::copy_from_slice 或 frame.slice(..) 成拥有视图,再 await 下游。借用缩进同步块,跨 await 只移动拥有的值或 Arc。
锁守卫跨 await 是同一问题的加锁版。std::sync::MutexGuard 不是 Send,跨 await 往往直接编不过;即便用 parking_lot 或 tokio::sync::Mutex 编过了,你也在持锁时把任务挂起,别的任务来抢这把锁只能排队,worker 还可能被这个挂起的任务占着调度语义。正确形状:锁里只做内存操作,I/O 留在锁外。
Pin 出现在自引用 future 上:状态机字段里有指向自己另一个字段的引用,move 这个 future 会让内部引用失效。Pin<&mut Fut> 承诺不再把 Fut 从内存里搬走。你几乎不必自己 unsafe impl Unpin;把跨 await 的引用改成拥有数据,自引用往往自己消失。
9. 生产 API:谁该拥有,谁该只看一眼
签名是契约。几个反复出现的选择:
输入缓冲。 解析、校验、计算哈希:收 &[u8]。要存储、要跨任务、要回复下游:收 Bytes 或 Vec<u8>。不要收 &Vec<u8>——那比 &[u8] 更窄,还没带来所有权。不要无故收 String 再立刻 as_bytes()。
配置和上下文。 请求级上下文用引用或 Arc<Ctx>。Arc 的含义是「我不知道谁最后一个用完」。如果生命周期能绑在 &Request 上,就不要 Arc。每个 Arc 都是一次原子计数和一次间接。
返回值。 能返回 impl Iterator<Item = &T> + '_ 就不要返回 Vec<T>。调用方只遍历一次时,你的 collect 是多余分配。需要拥有的快照(要跨锁释放后还读)才 clone 出来。
错误。 见错误处理那篇。这里只强调所有权侧面:thiserror 的枚举若包含 String 或 Box<dyn Error>,等于错误路径也在分配。热路径上的可恢复错误,优先静态变体和 &'static str。
资源。 File、TcpStream、数据库连接必须有清晰的单一所有者。连接池把所有权租出去,归还时再收回。不要 clone 一个连接对象——TcpStream 的 try_clone 是复制 fd,语义是两个所有者关两次,和 Arc<Mutex<Conn>> 完全不同。
一个网关 handler 的常见形状:
struct Request {
method: Method,
path: Bytes,
body: Bytes,
}
async fn handle(req: Request, peers: Arc<PeerMap>) -> Response {
let route = parse_path(req.path.as_ref()); // 借用,同步,立刻结束
match route {
Route::Get(id) => lookup(&peers, id).await,
Route::Put(id) => write_body(id, req.body).await, // body 所有权进入写任务
}
}
path 只需要看一眼,但因为 Request 本身要跨 await,它已经是 Bytes 而不是 &str。body 可能很大,move 进写路径,避免再拷一份。peers 是进程级共享,Arc 名正言顺。
类型状态(typestate)把「这扇门能不能写」编进类型,而不是编进运行时的 if self.closed。典型形状:File<Closed> 没有 write 方法;open 把所有权吃掉,吐出 File<Open>。非法状态不可表示,比文档里写「先 open 再 write」硬。代价是泛型和类型爆炸,以及跨 FFI/序列化时状态被擦掉。适合连接握手、协议阶段、一次性地消耗的 token。不适合每两个字段就拆一个类型参数——那是把借用检查器没问你的问题提前问了。
10. 取舍:clone、Arc、channel 各买什么
三条路都能让借用检查器闭嘴,买到的东西不一样。
clone()。 买独立副本。对 Arc/Bytes 是 bump 计数(浅);对 Vec/String/HashMap 是分配加 memcpy(深)。热路径上深 clone 的火焰图长得很认:分配器、memcpy、然后 GC 式的 drop 浪潮。可以 clone 的信号:副本很小、频率低、你确实需要独立生命周期。不要为了「过编译」clone 整个请求体。
Arc<T>。 买共享所有权。适合只读的大对象、配置、连接池、下游客户端。可变状态要再加 Mutex/RwLock/原子,否则 T 不是 Sync。代价是:间接、缓存不友好、循环引用要 Weak、调试时「谁还握着最后一票」不直观。Arc<Mutex<HashMap<..>>> 是共享可变的默认形状,也是争用的默认形状——下一层优化往往是拆分所有权,而不是换一把更快的锁。
channel。 买「所有权随消息走」。任务 A 不再碰这块数据,任务 B 独占。这是并发里最接近单所有权的模型。有界 channel 还顺手买到背压。代价是延迟和拷贝(或 move)边界;消息里不要塞大锁和大缓冲,塞 id 或 Bytes。
还有一条常被忽略:把可变表面变小。一个 Session 里同时塞 socket、解析器状态、用户信息、限流计数,任何方法都是 &mut self,异步和借用会一起爆炸。拆成 IoHalf、ParseState、Arc<User>,每个任务只拿自己那一份,借用检查器会安静很多。安静不是因为它被绕过,是因为别名本来就不该那么宽。
split 出读写半边(TcpStream::split / into_split)就是这个原则的 IO 版:读任务独占读半边,写任务独占写半边,不再为了「同一条连接」去抢一把 Mutex<TcpStream>。内核的 tcp socket 本来就能同时 read/write;把这个事实反映到所有权上,比在用户态加锁更接近硬件。代价是两个半边的生命周期要一起管理,一边 drop 要让另一边知道连接没了。
Cow<'a, str> 和 Cow<'a, [u8]>买的是「大多只读,偶尔要拥有」。配置覆盖、路径规范化、小写化这种「可能改一个副本」的路径适合它。它不是万能缓冲。协议热路径用Bytes` 更直接。
11. 排障:编译器在说哪条不变量破了
遇到借用错误,按层问,不要先加 clone:
- 是 move 还是 borrow?
use of moved value是所有权被转走;cannot borrow as mutable是别名冲突。两种修法完全不同。前者考虑Option::take、改签名拿走或只借;后者考虑缩小借用范围、拆字段、改成内部可变性。 - 冲突的两条路径是什么? 读错误里的
first borrow/second borrow。画出两个路径是否真的重叠。不重叠却报错,通常是因为你借的是整个self而不是self.a。把方法改成关联函数fn bump(stats: &mut Stats)往往立刻好。 - 是否跨了
await? 把await前后切开:前面同步解析,后面只拿拥有的值。spawn报not Send或lifetime,先看闭包环境里有没有&、RefCell、裸指针。 - 是否把锁守卫、迭代器、切片和
self同时活过一次可变调用? 三个典型写法:let n = self.map.len(); self.map.insert(..)(len 的借用该结束却被你写成了长借);for k in self.map.keys() { self.map.remove(k); };let g = lock.lock(); foo().await; drop(g)。 - 生命周期标注是不是在撒谎? 结构体上的
'a必须能指回一个真正的所有者。若所有者在堆上、在连接池里、在另一个任务里,'a写不对。改成拥有Bytes/Arc,标注会自己消失。
编译器附带的 help: consider cloning 是机械建议,不是架构建议。每次接受它之前问:我需要的是独立副本,还是我还没找到那个该结束的借用?
一段会反复出现的报错:cannot borrow *self as mutable because it is also borrowed as immutable,出现在 self.inner.do_x(); self.metrics.bump(); 这种看起来无害的连续调用上。原因是 do_x 的 &mut self 把整个 self 借走,不是只借 inner。把 bump 改成关联函数,或先把需要的字段拆出来:
fn record(inner: &mut Inner, metrics: &Metrics) {
inner.do_x();
metrics.bump();
}
调用处 record(&mut self.inner, &self.metrics)。字段分裂借用是合法的,方法接收者 &mut self 不是。这不是风格,是别名粒度。结构体越大,&mut self 的杀伤力越大。拆方法接收者,往往比拆 crate 更能让生产代码编过。
再看一类和生产事故直接相关的错误:cannot return value referencing local variable。你在函数里读完 socket 到局部 Vec,再返回指向它的 &[u8]。编译器拒绝,是第三条不变量:借用不能活过所有者。修法不是 transmute 拉长寿命,是把 Vec 或 Bytes 一起返回。另一个变种是把引用存进 lazy_static / OnceLock:全局的寿命是 'static,局部缓冲不是。缓存要拥有键和值。自引用结构(结构体里既有 buf: Vec<u8> 又有 view: &'self [u8])在安全 Rust 里几乎写不成,这是 Pin 和 ouroboros 一类库存在的原因。生产里宁可 view 改成偏移量 start/end: usize,用方法临时切 &self.buf[start..end]。偏移量是拥有的整数,不引入别名;切片在方法返回时结束。这比自引用少一个数量级的 unsafe。
Cow<'a, str> 在 API 边界上的正当用法是:调用方多数时候能借,少数时候你必须改(小写化、去空格)才分配。内部存储仍应决定是长期借还是长期拥有——Cow 只是这一次调用的形状。把它塞进结构体当字段,等于结构体有时借有时拥有,生命周期参数会污染所有方法。字段用 String/Bytes,参数用 impl AsRef<str> 或 Cow,是更稳的分层。
工具:cargo expand 看 async 状态机长什么样;RUSTFLAGS=-Zprint-type-sizes(nightly)看 future 有多大——跨 await 的大 Vec 会把任务结构撑到几 KB,缓存和分配都受伤。Arc::strong_count 在调试泄漏时有用,但不要在热路径上当逻辑。
12. 小结
所有权是别名的类型系统。所有者决定谁 drop;借用决定谁能同时看;生命周期给「能看到何时」起名;Send/Sync 把同一套规则延伸到线程;内部可变性是受控的例外;跨 await 只是「这份状态机会被挪到堆上」的推论。
生产里值钱的不是把生命周期标注写得更花,是把可变表面收小、把跨任务的数据改成拥有或引用计数切片、把 clone 留给我确实需要副本的地方。借用检查器逼你把这些话说清楚。说清楚之后,use-after-free 和数据竞争就不再是「线上再看一眼」的问题。下一篇把「任务被挪到另一条线程」展开:Tokio 的调度、取消,以及为什么阻塞 worker 等于偷走别人的 IO。