跳转到内容

1.5 生命周期:怎样保证引用指向的数据有效

上一节我们为模型设计出了剥离释放责任的引用(&T):真正负责释放堆内存的是原变量 s(String),而引用 &s(&String)只在栈上保存 s 的 8 字节地址,离开作用域时绝不释放堆。

上一节末尾留了一个追问:既然引用自己不管释放,那块堆什么时候释放完全听原变量的——万一负责释放的原变量先一步离开作用域、把堆内存释放掉了,而某个引用还留在外面的变量手里,会发生什么?

原变量先退场:引用手里的地址瞬间悬空

Section titled “原变量先退场:引用手里的地址瞬间悬空”

不用复杂的函数,只要一个内层代码块 {},就能把这个漏洞构造出来:

{
r; // 外层先声明一个变量 r,准备存放引用
{
x = alloc_heap("hello")
r = &x // 把内层变量 x 的栈地址交给外层的 r
} // ← 内层结束,x 在这里离开作用域
print(r) // ← 顺着 r 里的地址去读,会发生什么?
}

按我们已经立下的模型规则,一步步推演这段代码的执行过程:

  1. 在内层代码块里,变量 x 申请了堆上的 "hello",并负责在离开内层 {} 时释放它;
  2. r = &x 把 x 在栈上的地址抄给了外层变量 r。因为引用剥离了释放责任,释放责任依然留在 x 身上;
  3. 走到内层 } 闭合:x 离开作用域,触发「离开作用域就释放」——x 在栈上的三格控制头消失,它指向的那块堆资源 "hello" 被当场归还给分配器;
  4. 回到外层代码块:变量 r 还没离开作用域,它栈上的 8 个字节里,依然原封不动地存着刚才 x 的地址。下一行 print(r) 顺着这个地址摸过去,访问到的是一块已经失效的内存。
内层还在时,r 指着栈上的 x,x 再指着堆数据;内层结束,x 退场并释放堆数据,r 手里的地址顿时悬空。
引用比它指向的变量活得更久两种状态。内层还在时,外层的引用 r 指向内层的栈变量 x,x 指向堆上的数据。内层结束后,内层变量 x 已离开作用域,堆数据随之释放,r 仍有一条虚线箭头指向已经消失的 x。内层还在栈r外层x 的栈地址x内层ptr(指向堆)堆堆数据 "hello"内层结束栈r外层x 的栈地址x 已离开作用域堆堆数据 "hello"

这就是引用自带的安全漏洞——悬垂引用(Dangling Reference)。

注意它和第三节 Move 之后再用旧变量的区别:Move 之后是原变量自己已经失效了;而这一次,引用变量 r 自己还在作用域里,失效的是它指着的那个原变量 x。

诊断:为什么运行时拿它没有办法?

Section titled “诊断:为什么运行时拿它没有办法?”

面对 print(r) 这一行即将发生的内存错误,程序在运行时能顺着 r 发现异常并拦住它吗?

看一眼 r 在内存里的物理表示就明白了:在运行时,引用 r 在栈上只是孤零零的 8 个字节(一个内存地址)。

这 8 个字节里只存着目标地址,没有任何一位记录着「我指向的那个 x 还在不在」。当 CPU 执行到 print(r) 时,它只管顺着这个地址去内存里取字节,根本无从分辨那个地址上的数据是活着的,还是刚刚随着内层 } 闭合被回收的。

如果非要在运行时防止这次非法读取,只有两条老路:

  1. 因为 r 还指着,就不让 x 在 } 处释放数据:等什么时候 r 也不用它了再释放——这等于把释放时机重新交回给运行时(也就是 GC 或引用计数),直接推翻了我们从第一节立下的「非 GC」前提;
  2. 给每块内存额外维护一张运行时有效状态表:每次顺着引用读数据之前,先在运行时查表核对目标内存有没有被释放——这意味着每一次最普通的读取都要付出额外的运行时检查开销,性能底线同样不允许。

既不能延后释放,又不能在运行时加检查指令,运行时的路都走不通。

结论很直接:在不增加运行时开销的前提下,悬垂引用无法在运行时拦截,必须在程序运行之前——也就是编译期——由编译器发现风险并拒绝编译这段代码。

编译期的解法:自动比对两段有效范围

Section titled “编译期的解法:自动比对两段有效范围”

如果让编译器在编译期替我们把关,它需要看什么才能发现上面这段代码的漏洞?

其实非常直观——编译器只需要顺着源码算出两段范围,然后做一次最简单的比对:

  • 原变量 x 的存活范围:从 x 被创建的那一行开始,到它所在代码块闭合的 } 为止;
  • 引用 r 的使用范围:从 r = &x 拿到地址开始,到最后一次使用 r(即 print(r))的那一行为止。

把这两段范围画成横条,安全与否一目了然:

引用 r 的有效区间必须落在原变量 x 的存活区间之内;一旦超出,引用就悬垂。
引用的有效区间必须落在原变量的区间里面两块面板。合法的一块里,引用 r 的横条比原变量 x 短,并且完全落在 x 的横条之内。超出的一块里,引用 r 的横条伸过了 x 的终点,画成虚线。合法(落在里面)代码推进 →原变量 x引用 r悬垂(超出终点)代码推进 →原变量 x引用 r
  • 左图(合法):引用 r 的使用范围,完全落在原变量 x 的存活范围之内——只要还在用 r,x 就肯定还没离开作用域,读取绝对安全;
  • 右图(悬垂):引用 r 的使用范围,伸出了原变量 x 的终点之外——x 已经离开作用域把堆释放了,r 在后面还要去读,编译器当场拒绝编译。

规则只有一句话:引用的使用范围,绝对不能超出原变量的存活范围(引用不能活过它指向的数据)。

在 Rust 里,这套由编译器在编译期追踪并核对「有效范围」的机制,就叫生命周期(Lifetime)。

停下来看一眼,补上编译期的有效范围检查之后,我们从第一节出发搭起来的这套自动内存回收模型,把关键缺口补齐了:

  1. 栈变量离开作用域(}),自动释放它管理的堆资源——不用手写 free,也不用运行时 GC,不会因为漏写而内存泄漏;
  2. 带着释放责任的值赋值走 Move 语义,旧变量当场失效——保证同一块堆同一时刻只由一个变量负责释放,消除了双重释放;
  3. 引入剥离了释放责任的引用(&T),并由编译器在编译期核对它的有效范围(生命周期)——既能让函数和多处代码按 Copy 语义共享读取数据,又在编译期拦住了悬垂引用。

下一节是本章的最后一节。我们把这五节推出来的逻辑链收拢成一张对照表,看看这套机制在真实的 Rust 语言里分别叫什么名字,并做一个综合推演练习。