把所有权理解成「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. 三条不变量,对应三类事故

把规则写成事故语言,比背教科书有用:

  1. 单一所有者。对应 double-free、忘记 free、在已经 move 走的值上再跑析构。Vec 的缓冲区指针只该出现在一个 Vec 头里;第二个头如果也认为自己该 dealloc,进程就在分配器里炸掉。
  2. 可变与共享互斥。对应数据竞争、迭代时修改容器、把 &T 当成可以偷偷改。迭代器拿着指向元素的指针时,push 可能重分配,指针变悬空——所以 for x in &v { v.push(1); } 直接拒绝。
  3. 借用不长于所有者。对应 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:

  1. 是 move 还是 borrow? use of moved value 是所有权被转走;cannot borrow as mutable 是别名冲突。两种修法完全不同。前者考虑 Option::take、改签名拿走或只借;后者考虑缩小借用范围、拆字段、改成内部可变性。
  2. 冲突的两条路径是什么? 读错误里的 first borrow / second borrow。画出两个路径是否真的重叠。不重叠却报错,通常是因为你借的是整个 self 而不是 self.a。把方法改成关联函数 fn bump(stats: &mut Stats) 往往立刻好。
  3. 是否跨了 await? 把 await 前后切开:前面同步解析,后面只拿拥有的值。spawn 报 not Send 或 lifetime,先看闭包环境里有没有 &、RefCell、裸指针。
  4. 是否把锁守卫、迭代器、切片和 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)。
  5. 生命周期标注是不是在撒谎? 结构体上的 '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。