Lean 语言参考手册

12.2. 引用计数🔗

Lean 使用引用计数来管理内存。 每个已分配对象都会维护一个计数,记录有多少其他对象引用它。 新增引用时计数递增,丢弃引用时计数递减。 当引用计数降为零时,该对象便不再可达,也不可能再参与程序后续的执行。 系统会释放该对象并丢弃它对其他对象的全部引用,这可能进一步触发其他对象的释放。

引用计数有许多优点:

复用内存

如果某个对象的引用计数恰在需要分配另一个同样大小的对象时降为零,就可以安全地将原对象的内存复用于新对象。 因此,当待遍历的数据结构恰好只有一个引用时,许多常见的数据结构遍历(例如 List.map)都不必分配内存。

条件允许时原地更新

字符串和数组等原语类型(参见字符串数组)可以在数据共享时执行复制,而在数据未共享时原地修改。 只要待修改值只有这一个引用,这些原语类型上的许多操作就会直接修改值,而不是复制值。 这可以显著提升性能。 精心编写的 Array 代码既能避免不可变数据结构的性能开销,又能保留纯函数便于推理的特性。

可预测性

引用计数会在可预测的时刻递减。 因此,可以用引用计数对象管理文件句柄等其他资源。 在 Lean 中,Handle 无需显式关闭,因为它一旦不再可访问就会立即关闭。

更简单的 FFI

回收未使用的内存时,不需要移动由引用计数管理的对象。 这大幅简化了与 C 等其他语言所编写代码的交互。

引用计数的传统缺点包括更新引用计数带来的性能开销,以及无法识别和释放循环数据。 前一个缺点通过基于借用的分析得到缓解;这种分析可以省去许多引用计数更新。 不过,多线程代码要求线程之间同步引用计数更新,这也会带来显著的开销。 为降低这种开销,Lean 将值划分为可从多个线程访问的值和不可从多个线程访问的值。 单线程引用计数的更新速度可以远高于多线程引用计数,而且许多值只会在单个线程上访问。 这些技术相结合,大幅降低了引用计数的性能开销。 由于 Lean 的可验证片段无法创建循环数据,Lean 运行时没有检测循环数据的机制。 关于 Lean 中引用计数的实现,Ullrich and de Moura (2019)Sebastian Ullrich and Leonardo de Moura, 2019. “Counting Immutable Beans: Reference Counting Optimized for Purely Functional Programming”. In Proceedings of the 31st Symposium on Implementation and Application of Functional Languages (IFL 2019).提供了更多细节。

12.2.1. 观察唯一性🔗

要在 Lean 中编写高效代码,确保数组和字符串只有一个引用至关重要。 原语 dbgTraceIfShared 可用于检查数据结构是否存在别名。 调用它时,它会原样返回参数;如果参数的引用计数大于一,则打印所提供的跟踪消息。

🔗定义
dbgTraceIfShared.{u} {α : Type u} (s : String) (a : α) : α
dbgTraceIfShared.{u} {α : Type u} (s : String) (a : α) : α

a 是共享值(即其引用计数 RC(a) 大于 1),则显示给定消息;无论是否显示消息, 都原样返回 a

Lean.Parser.Command.eval : command#eval 具体实现方式的影响,将 dbgTraceIfSharedLean.Parser.Command.eval : command#eval 一同使用可能产生误导。 应当改在明确经过编译并运行的代码中使用它。

观察唯一性

该程序从用户处读取一行输入,将第一个字符替换为空格后打印出来。 如果字符串未被共享,且新旧字符都属于 Unicode 的 7 位 ASCII 子集,替换字符串中的字符时就会执行原地更新。 dbgTraceIfShared 调用没有任何输出,这表明字符串确实会原地更新,而不是先被复制。

def process (str : String) (h : str.startPos str.endPos) : IO Unit := do IO.println (String.Pos.set (dbgTraceIfShared "String update" str).startPos ' ' h) def main : IO Unit := do let line := ( ( IO.getStdin).getLine).trimAscii.copy if h : line.startPos line.endPos then process line h

使用以下输入运行时:

stdinHere is input.

程序输出:

stdout ere is input.

标准错误输出为空:

stderr<empty>

这个版本的程序保留了对原字符串的引用,因此调用 String.Pos.set 时必须复制字符串。 这一点可以从它的标准错误输出中看出。

def process (str : String) (h : str.startPos str.endPos) : IO Unit := do IO.println (String.Pos.set (dbgTraceIfShared "String update" str).startPos ' ' h) def main : IO Unit := do let line := ( ( IO.getStdin).getLine).trimAscii.copy if h : line.startPos line.endPos then process line h IO.println "Original input:" IO.println line

使用以下输入运行时:

stdinHere is input.

程序输出:

stdout ere is input.Original input:Here is input.

在标准错误中可以看到传给 dbgTraceIfShared 的消息。

stderrshared RC String update

12.2.2. 编译器中间表示(IR)🔗

编译器选项 trace.compiler.ir.result 可用于查看函数的编译器中间表示(IR)。 在这种中间表示中,引用计数、内存分配和复用都是显式的:

  • isShared 运算符检查引用计数是否为 1

  • ctor_n 分配某个类型的第 n 个构造器。

  • proj_n 从构造器值中取出第 n 个字段。

  • set x[n] 修改 x 中构造器的第 n 个字段。

  • ret x 返回 x 中的值。

引用计数操作的具体方式可能取决于内联等优化阶段的结果。 绝大多数 Lean 代码无须关注这些细节就能获得良好性能,但在编写性能关键型代码时,掌握如何诊断唯一引用相关的问题可能非常重要。

🔗选项
trace.compiler.ir.result

默认值:false

启用后,跟踪 Lean 编译器中间表示(IR)生成阶段的最终结果。

IR 中的引用计数

通过编译器中间表示(IR)可以观察引用计数何时递增,这有助于诊断以下情形:本以为某个值只有一个传入引用,但它实际上却被共享。 这里,processprocess' 都接受一个字符串参数,使用 String.Pos.set 修改它,并返回一对字符串。 process 将常量字符串作为二元组的第二个元素返回,而 process' 则返回原字符串。

set_option trace.compiler.ir.result true def [Compiler.IR] [result] def process._closed_0 : obj := let x_1 : obj := ""; ret x_1 def process (x_1 : obj) : obj := let x_2 : tagged := 0; let x_3 : u32 := 32; let x_4 : obj := String.Pos.Raw.set x_1 x_2 x_3; let x_5 : obj := process._closed_0; inc x_5; let x_6 : obj := ctor_0[Prod.mk] x_4 x_5; ret x_6process (str : String) : String × String := (String.Pos.Raw.set str 0 ' ', "") def [Compiler.IR] [result] def process' (x_1 : obj) : obj := let x_2 : tagged := 0; let x_3 : u32 := 32; inc x_1; let x_4 : obj := String.Pos.Raw.set x_1 x_2 x_3; let x_5 : obj := ctor_0[Prod.mk] x_4 x_1; ret x_5process' (str : String) : String × String:= (String.Pos.Raw.set str 0 ' ', str)

process 的 IR 中不包含 incdec 指令。 如果传入的字符串 x_1 是唯一引用,那么将它传给 String.Pos.set 时,它仍然是唯一引用,因此可以就地修改:

[Compiler.IR] [result]
    def process._closed_0 : obj :=
      let x_1 : obj := "";
      ret x_1
    def process (x_1 : obj) : obj :=
      let x_2 : tagged := 0;
      let x_3 : u32 := 32;
      let x_4 : obj := String.Pos.Raw.set x_1 x_2 x_3;
      let x_5 : obj := process._closed_0;
      inc x_5;
      let x_6 : obj := ctor_0[Prod.mk] x_4 x_5;
      ret x_6

另一方面,process' 的 IR 会在调用 String.Pos.set 之前递增该字符串的引用计数。 因此,无论 x_1 的原始引用是否唯一,修改后的字符串 x_4 都是一个副本:

[Compiler.IR] [result]
    def process' (x_1 : obj) : obj :=
      let x_2 : tagged := 0;
      let x_3 : u32 := 32;
      inc x_1;
      let x_4 : obj := String.Pos.Raw.set x_1 x_2 x_3;
      let x_5 : obj := ctor_0[Prod.mk] x_4 x_1;
      ret x_5
IR 中的内存复用

函数 discardElemsList.map 的简化版本,它将列表中的每个元素替换为 ()。 查看其中间表示可以看出,当列表的引用唯一时,它会复用列表的内存。

set_option trace.compiler.ir.result true def [Compiler.IR] [result] def discardElems._redArg (x_1 : tobj) : tobj := case x_1 : tobj of List.nil → let x_2 : tagged := ctor_0[List.nil]; ret x_2 List.cons → let x_3 : tobj := proj[1] x_1; block_4 (x_5 : tobj) (x_6 : u8) := let x_7 : tagged := ctor_0[PUnit.unit]; let x_8 : tobj := discardElems._redArg x_3; block_9 (x_10 : obj) := ret x_10; case x_6 : u8 of Bool.false → set x_5[1] := x_8; set x_5[0] := x_7; jmp block_9 x_5 Bool.true → let x_11 : obj := ctor_1[List.cons] x_7 x_8; jmp block_9 x_11; let x_12 : u8 := isShared x_1; case x_12 : u8 of Bool.false → let x_13 : tobj := proj[0] x_1; dec x_13; jmp block_4 x_1 x_12 Bool.true → inc x_3; dec x_1; jmp block_4 ◾ x_12[Compiler.IR] [result] def discardElems (x_1 : ◾) (x_2 : tobj) : tobj := let x_3 : tobj := discardElems._redArg x_2; ret x_3discardElems : List α List Unit | [] => [] | _ :: xs => () :: discardElems xs

这会生成如下 IR:

[Compiler.IR] [result]
    def discardElems._redArg (x_1 : tobj) : tobj :=
      case x_1 : tobj of
      List.nil →
        let x_2 : tagged := ctor_0[List.nil];
        ret x_2
      List.cons →
        let x_3 : tobj := proj[1] x_1;
        block_4 (x_5 : tobj) (x_6 : u8) :=
          let x_7 : tagged := ctor_0[PUnit.unit];
          let x_8 : tobj := discardElems._redArg x_3;
          block_9 (x_10 : obj) :=
            ret x_10;
          case x_6 : u8 of
          Bool.false →
            set x_5[1] := x_8;
            set x_5[0] := x_7;
            jmp block_9 x_5
          Bool.true →
            let x_11 : obj := ctor_1[List.cons] x_7 x_8;
            jmp block_9 x_11;
        let x_12 : u8 := isShared x_1;
        case x_12 : u8 of
        Bool.false →
          let x_13 : tobj := proj[0] x_1;
          dec x_13;
          jmp block_4 x_1 x_12
        Bool.true →
          inc x_3;
          dec x_1;
          jmp block_4 ◾ x_12[Compiler.IR] [result]
    def discardElems (x_1 : ◾) (x_2 : tobj) : tobj :=
      let x_3 : tobj := discardElems._redArg x_2;
      ret x_3

在 IR 中,List.cons 分支会显式检查参数值是否被共享(即其引用计数是否大于一)。 如果引用唯一,则会递减被丢弃的列表元素 x_5 的引用计数,并复用构造器值。 如果引用被共享,则会在 x_11 中为结果分配一个新的 List.cons