Nim ARC&ORC GC 笔记

705 阅读17分钟

Nim ARC&ORC GC

这是来自国外的一篇 Nim 教程的翻译及笔记,并且添加了额外的一些内容。

来源:

介绍

ARC GC是 Nim 自从 v1.2 开始引入的新的高性能GC. 要启用这个GC,只需在编译时使用命令行参数 --gc:arc

Garbage collection

Nim的内存管理模型引入了两种不同的指针类型:

  1. ref指针类型:这是被Nim管理的指针,如果指向的数据不再使用,Nim将自动帮你释放。在绝大部分场景下,我们应该使用这种指针类型,除非有什么必须的理由。创建一个 ref 类型的指针的唯一方法是使用 new() 函数来在堆上构建一个指定类型的对象或变量。
  2. ptr指针类型:这是未被Nim管理的指针,指向的数据需要程序员手动分配和释放,正如 C 程序中使用 alloc()free() 那样。

Ref 引用在 Nim 中是通过垃圾回收器(Garbage Collector, 缩写为 GC)处理的。

有许多类型的 GC,但是他们大多通过相同的方式运作:偶尔阻塞你的程序,将控制权交由 GC 接管,它将查看被分配的内存块是否还在被使用,例如,还有没有指针在引用这块内存。如果一块内存没有指针指向它,那么 GC 就认为这块内存可以被释放了。

GC 非常棒,因为它让程序员从持续追查内存的痛苦中解放出来,并且得到了更安全的代码(没有 use-arfer-free 以及更少的内存泄漏)。

但是 GC 也有几个不利因素:

  • GC是一个需要周期性运行的进程,并且需要花费一定时间。这有可能干涉你的应用程序——在你的游戏以60fps绘制帧时,你可能不希望GC打断20毫秒来完成它的工作;
  • GC实际上经常是不确定的:作为程序员,你不能控制究竟哪个资源被清除了,你不能说“我想要让这块内存立即被释放”;
  • GC的代码需要作为你的程序的一部分。这在桌面、服务器程序不是问题,但在小的嵌入式程序中会需要考量;
  • 垃圾处理器在多线程时表现不良。这让多线程间共享内存成为了一件很难甚至几乎不可能的事。

Introducing: ARC

Nim 1.2 版本带来了一个新的内存管理系统,叫做ARC。在ARC中,编译器对内存管理变得更智能,以至于不再需要运行时 GC。

传统 GC 的运作方式是周期性中断程序,然后检查没有被引用的内存。作为对传统 GC 的替代,ARC 建立在这几个概念上:引用计数(Reference Counting)和析构函数(Destructor)。

ORC GC

ORC 在 ARC 的基础上进行了一定的改进,通过添加一个运行时引用环回收器(Cycle Collector),解决了 ARC 中棘手的引用环释放问题。由于有额外的运行时结构,性能比起 ARC 略逊一筹,但仍然比传统 GC 要好上不少。

引用计数

在 ARC 中,Nim 将追踪每块内存的引用计数(reference count,也叫 refcount/rc)。引用计数只是简单的整数,用来记录有多少指针指向这块内存。Nim 将自动保证每次指针复制,计数器都会追加,每次指针被“丢弃”,计数器都会减少。例如,你的指针局部变量过了作用域,这个指针就会自动被丢弃;或者你的指针是某个对象内部的成员,而这个对象现在要被销毁。

当某个对象的最后一个指针消失,引用计数将会为0。这意味着这块内存没有任何引用,于是 Nim 通过析构函数的帮助,就可以释放这块内存。

析构函数

析构函数是一种当引用计数降为0时,Nim自动调用的函数。每个类型都有自己的析构函数,这是编译器自动生成的。

尽管编译器生成的默认析构函数会正确释放内存,但是它也只能做到这里。在某些情况下你还需要自定义析构函数来释放其他类型的资源,例如文件描述符(File Descriptor)、套接字(Socket)以及其他外部库提供的句柄(Handle)。这时,你需要 =destroy() 钩子。

移动语义

上述的两个概念——引用计数和析构函数,基本上是管理内存所需的一切了:它让Nim有能力追踪有多少指针指向某块内存,并且释放不再需要的内存。

尽管听起来很简单直接,编译器其实在背后也做了许多聪明的事情来让它变得更高效,确保如无必要,不做多余的工作。

这就是移动语义(Move semantics)的由来了。移动语义允许你的代码在某些情况下,将开销昂贵的内存拷贝变成廉价的指针移动。

移动是复制的优化操作。如果复制的原对象不再使用,那么复制就会被替换成移动。本文中使用记号lastReadOf(x)来声明x在之后不再使用。这个操作是通过静态控制流分析来自动计算的,但是也可以通过使用 system.move 来强制执行。

生命周期管理钩子

Nim 标准 stringseq 类型就像其他的标准容器一样,依赖于名为“生命周期管理钩子”的技术,事实上,生命周期钩子是一种[[类型绑定操作符]]。

对于每个 object 类型,有三种(无论泛型还是具体)不同的生命周期钩子被编译器隐式调用。

[!note] “钩子”(hook)在这里并不暗示任何类型的动态绑定或运行时重定向,隐式调用是静态绑定的,并且可能被内联。

1. =destroy 钩子

=destroy钩子用来释放对象相关的内存和其他与之关联的资源。当变量离开作用域或变量被定义在的例程将要返回时,这个钩子将会析构该变量。

这个钩子的原型是:

proc `=destroy`(x: var T)

=destroy中的一般模式是这样的:

proc `=destroy`(x: var T) =
	# 首先检查 'x' 有没有被移动到什么其他地方:
	if x.field != nil:
		freeResource(x.field)

2. =sink 钩子

=sink钩子将对象移动到其他地方,资源将会被从原对象“偷”出来,然后送到目的对象。通过将对象设置为默认值,确保原对象的析构函数不会释放任何资源。让某个对象 x 回到他的初始值可以使用 wasMoved(x)。当编译器没有提供这个方法时,使用=destroycopeMem组合来代替它。这是很有效的,因此用户很少需要实现自己的 =sink 操作符,实现=destroy=copy就足够了,编译器将会处理剩下的部分。

对于某个类型 T, 这个函数的原型如下:

proc `=sink`(dest: var T; source: T)

=sink 中的一般模式是这样的:

proc `=sink`(dest: var T; source: T) =
	`=destory`(dest)
	wasMoved(dest)
	dest.field = source.field

[!note] =sink不需要检查自我赋值(self-assignment)。如何处理自我赋值将在下文中提及。

3. =copy 钩子

普通的赋值,Nim字面意义上的将值复制过去。=copy钩子在=sink无法使用时才会被调用。

对于某个类型 T,这个函数的原型如下:

proc `=copy`(dest: var T; source: T)

=copy 中的一般模式是这样的:

proc `=copy`(dest: var T; source: T) =
	# 避免自我赋值:
	if dest.field != source.field:
		`=destroy`(dest)
		wasMoved(dest)
		dest.field = duplicateResource(source.field)

=copy函数可以被{.error.}Pragma 标记。如果使用了该标记,那么任何尝试使用=copy来赋值的操作都将在编译期被阻止。

proc `=copy`(dest: var T; source: T) {.error.}

但是在这种用法下,编译器将不会发送 error pragma 的自定义错误信息(例如{.error: "custom error".})。

4. =trace 钩子

自定义的容器类型可以通过=trace钩子,在-mm:orcGC下支持Nim的引用环回收器(Cycle Collector)。如果一个容器不实现=trace,成环的数据结构可能会造成内存泄漏,但是内存安全仍然受到保障。

对于某个类型 T,这个函数的原型如下:

proc `=trace`(dest: var T; env: pointer)

ORC 使用env 指针来持续追踪内部状态,它应该传递给内置=trace操作的调用。

通常只有一种情况下,我们需要自定义 =trace 钩子:当一个自定义的=destroy钩子手动释放了一个用完的资源,并且这个资源中的项有可能进行循环引用时,自定义=trace钩子使 ORC 的引用环回收器工作,切断这个环然后正常回收。但是目前,有一个相互调用问题,就是最先被调用的=destroy/=trace会自动创建它的另一半(如果是=destroy就会创建=trace,因为此时=trace还未被声明),这将与接下来对它的声明产生冲突。这个问题的解决方法是前向声明第二个钩子来避免自动创建。

=destroy=trace的常见代码片段如下:

type Test[T] = object
	size: Natural
	arr: ptr UncheckedArray[T] # 这是原始指针字段,不确定是否可能循环

proc makeTest[T](size: Natural): Test[T] = # 自定义内存分配...
	Test[T](size: size, arr: cast[ptr UncheckedArray[T]](alloc0(sizeof(T) * size)))

proc `=destroy`[T](dest: var Test[T]) =
	if dest.arr != nil:
		for i in 0 ..< dest.size: dest.arr[i].`=destroy`
		dest.arr.dealloc

proc `=trace`[T](dest: var Test[T]; env: pointer) =
	if dest.arr != nil:
		# 追踪可能循环的类型`T`
	for i in 0 ..< dest.size: `=trace`(dest.arr[i], env)

# 接下来可能是其他需要的自定义钩子...

[!note] =trace钩子仅在 ORC 内存管理模式(-mm:orc)中使用,并且目前更加类似于实验状态,比起其他钩子而言相对粗糙。

交换(Swap)

检查自我赋值的需要以及销毁 =copy=sink 内部先前对象的需要是将 system.swap 视为内置原语的有力指标,交换只是通过 copyMem 或类似机制交换相关对象中的每个字段。换句话讲,swap(a, b) 的实现与 let tmp = move(b); b = move(a); a = move(tmp) 不同。

这造成了几个结果:

  • 包含指向同一物体的多个指针的对象在 Nim 的内存模型中不支持。否则,被交换的物体将会处在一种不确定的状态。
  • 队列(Seq)在它的实现中可以使用realloc

Sink 参数

将某个变量移动到一个容器中常常使用sink参数。传递给sink参数的地址在之前应该是未被使用的,这确保了控制流图中静态分析的准确性。如果不能推断出该地址的最后用例,编译器将会复制一份来代替原本,传递到sink参数中。

sink 参数或许会在函数体中被识别为消耗一次,然而实际上并没有被消耗。这是出于以下的原因:

  • proc put(t: var Table; k: sink Key, v: sink Value) 这样的函数签名应该可以使用而不需要进行多余的重载;
  • put 或许不一定会夺取 k 的所有权(如果 k 已经在这个Table中)。

Sink 参数开启了一种仿射类型系统(Affine Type System),而不是线性类型系统(Linear Lype System)。

静态分析是有限制的,并且仅考虑局部变量;但是,对象和元组的字段被作为独立的实体对待:

proc consume(x: sink Obj) = discard # 未实现

proc main() =
	let tup = (Obj(), Obj())
	consume tup[0]
	# 只有 tup[0] 被消耗,tup[1] 仍然存活
	echo tup[1]

有些时候,还需要显式调用move来移动一个值到它的最终位置:

proc main() =
	var dest, src: array[10, string]
	# ...
	for i in 0..high(dest): dest[i] = move(src[i])

对它的实现是允许的,但是不需要实现更多的优化(目前的实现没有这么做)。

[!warning] 原文为 An implementation is allowed, but not required to implement even more move optimizations (and the current implementation does not).

此段翻译不确定。

Sink 参数推理

目前的实现可以做一些有限的 sink 参数推理。但是者必须开启编译指示 --sinkInference:on,通过在命令行中启用或通过 push pragma.

可以使用 {.push sinkInference: on} ... {.pop.}

{.nosinks.} pragma 可以用来对某个例程单独禁用 sink 类型推理。

proc addX(x: T; child: T) {.nosinks.} =
	x.s.add child

类型推理的算法细节目前没有文档。

重写规则(Rewrite rules)

对于重写规则,有两种不同的实现策略,它们都是被允许的:

  1. 产生的finally阶段可以作为一个独立的阶段包裹整个例程体。
  2. 产生的finally阶段包裹在封闭的域中。

目前的实现是策略 (2)。这意味着资源将会在离开作用域时销毁。

var x: T; stmts 
--------------- (destroy-var) 
var x: T; try stmts 
finally: `=destroy`(x) 


g(f(...)) 
------------------------ (nested-function-call) 
g(let tmp; 
bitwiseCopy tmp, f(...);
tmp) 
finally: `=destroy`(tmp) 


x = f(...) 
------------------------ (function-sink) 
`=sink`(x, f(...)) 

x = lastReadOf z 
------------------ (move-optimization) 
`=sink`(x, z) 
wasMoved(z) 


v = v 
------------------ (self-assignment-removal) 
discard "nop"


x = y 
------------------ (copy) 
`=copy`(x, y) 


f_sink(g()) 
----------------------- (call-to-sink) 
f_sink(g()) 


f_sink(notLastReadOf y) 
-------------------------- (copy-to-sink) 
(let tmp; `=copy`(tmp, y); 
f_sink(tmp)) 


f_sink(lastReadOf y) 
----------------------- (move-to-sink) 
f_sink(y) 
wasMoved(y)

对象和数组的构造函数

当函数有sink参数的时候,对象和数组的构造函数被看作函数调用。

析构函数的去除(Removal)

wasMoved(x)=destroy(x)连用将会互相抵消。鼓励利用这一点,以提高效率,降低代码大小。目前的实现已经启用了这个优化。

自我赋值(Self assignments)

=sinkwasMoved组合可以处理自我赋值,但是比较微妙。

最简单的例子 x = x 不能被转换为 =sink(x,x); wasMoved(x) 因为这将会使得 x 的值丢失。以下这些简单的自我赋值情况:

  • 符号自我赋值:x = x
  • 通过字段自我赋值:x.f = x.f
  • 数组,链表或字符串在编译期可知的自我赋值: x[0] = x[0]

它们将会被在编译期删除,不做任何事。编译器还可以自由地优化进一步的情况。

复杂的类型例如 x = f(x),例如我们考虑 x = select(rand() < 0.5, x, y)

proc select(cond: bool; a,b: sink string): string =
	if cond:
		result = a # 将 a 移动到结果中
	else:
		result = b # 将 b 移动到结果中

proc main =
	var x = "abc"
	var y = "xyz"
	# 可能出现自我赋值:
	x = select(true, x, y)

将会被转换为:

proc select(cond: bool; a, b: sink string): string =
	try:
		if cond:
			`=sink`(result, a)
			wasMoved(a)
		else:
			`=sink`(result, b)
			wasMoved(b)
	finally:
		`=destroy`(b)
		`=destroy`(a)

proc main =
	var
		x: string 
		y: string 
	try: 
		`=sink`(x, "abc") 
		`=sink`(y, "xyz") 
		`=sink`(x, select(true, 
			let blitTmp = x 
			wasMoved(x) 
			blitTmp, 
			let blitTmp = y 
			wasMoved(y) 
			blitTmp))
		echo [x]
	finally: 
		`=destroy`(y) 
		`=destroy`(x)

可以手动验证,此转换对于自我赋值是正确的。

借用类型(Lent type)

proc p(x: sink T) 意味着函数 p 将会夺取 x 的所有权。为了消除更多的构造/复制 <-> 析构对,函数的返回值可以被标注为 lent T。这对于 getter 而言非常有用,可以用来提供对容器内部的不可变访问。

sinklent标记让我们可以消除大部分的多余复制和析构。

lent T 就像 var T,是一个隐式的指针。编译器保证这个指针的生命周期将不会超过它的来源。在 lent Tvar T 的表达式中,将不会有析构函数被调用。

type
	Tree = object
		kinds: seq[Tree]

proc construct(kids: sink seq[Tree]): Tree =
	result = Tree(kids: kids)
	# 这将转换为:
	result.kids = sink(kids)
	kids.wasMoved()
	kids = destroy

proc `[]`*(x: Tree; i: int): lent Tree =
	result = x.kids[i]
	# 'x' 的借用,这将转换为
	result = addr x.kids[i]
	# 这意味着 'lent' 与 'var T'相似,是隐式的指针。
	# 与 `var` 不同的是,这个指针不可用于修改该物体。

iterator children*(t: Tree): lent Tree =
	for x in t.kids: yield x

proc main() =
	# 一切都将被转换成移动
	let t = construct(@[
		construct(@[]), 
		construct(@[])
		])
	echo t[0] # 这个访问将不会复制元素!

{.cursor.} 标记

--mm:arc-mm:orc 模式下,Nim 的类型实现相同的运行时钩子和引用计数。这意味着环状数据结构将不能被立即释放(--mm:orc 携带一个引用环回收器)。通过 {.cursor.} 标记可以通过声明的方式切断环结构:

type
	Node = ref object
		left: Node # 拥有所有权的引用
		right {.cursor.}: Node # 没有所有权的引用

但是请注意:这不是 C++ 的弱指针,这意味着 right 字段不会进行引用计数,它是原始的指针,没有运行时检查。

自动引用计数的不利之处在于它在迭代链式结构时引入了额外的开销。{.cursor.} 标记可以用来避免这一点:

var it {.cursor.} = listRoot
while it != nil:
	use(it)
	it = it.next

事实上,{.cursur.} 通常可以避免对象的构造/析构对,因此在其他的上下文中也很有用。它的替代是使用原始指针(ptr),这更麻烦,对 Nim 的后续发展也更危险。稍后,编译器可以尝试证明 {.cursor.} 标注是安全的,但对于 ptr,编译器只能对可能出现的问题保持沉默。

游标(Cursor)推断/复制省略

当前已经实现了 .cursor 推理。游标推断是一种形式的复制省略。

为了观察我们如何实现,以及怎样实现这件事,让我们想一想这个问题:在 dest = src 中,当什么时候,我们真的需要具体的完整副本?——仅在 destsrc 将会在之后被修改的情况下。如果 dest 是一个局部变量,src 是一个形参派生的变量,我们也知道它将不变!换句话说,我们进行编译期写时复制(copy-on-write)分析。

这意味着借用视图可以被自然的写下来而不需要显式标注。

proc main(tab: Table[string, string]) =
	let v = tab["key"] # 推断为.cursor, 因为 'tab' 是形参,在函数体内不会改变。
	# 不会复制到 'v' 中,'v' 也不会被析构。
	use(v)
	useItAgain(v)

写时复制(Copy on write)

字符串字面量被实现为写时复制。当将一个字符串字面量赋值给一个变量时,将不会创建字面量的副本。相反的,这个变量将会简单的指向这个字面量。这个字面量在不同的指向它的变量间将会共享。复制操作将会被避免,直到第一次对变量的修改。

例如:

var x = "abc" # 不会复制
var y = x # 不会复制
y[0] = 'h' # 复制并修改副本

这种操作对于 addr x 的抽象不成功,因为地址是否将由于可变量是未知的。需要在地址操作之前调用 prepareMutation。例如:

var x = "abc"
var y = x

prepareMutation(y)
moveMem(addr y[0], addr x[0], 3)
assert y = "abc"

Hook lifting

一个元组(A, B, ...)的钩子是通过提升它内含类型 A, B, ... 的钩子来生成的。换句话说,x = y 的复制的实现为 x[0] = y[0]; x[1] = y[1] ...=sink=destroy也一样。

其他基于值的复合类型例如 objectarray 将相应的进行处理。但是对于 object,编译器生成的钩子可以被重载。为了使用更有效的相关数据结构的替代遍历或避免深度递归,这也很重要。

Hook generation

重写钩子的能力导致了一个代码顺序问题:

type Foo[T] = object

proc main =
	var f: Foo[int]
	# 错误: 'f' 的析构函数在这里被调用,然而析构函数此时尚未被定义

proc `=destroy`[T](f: var Foo[T]) =
	discard

解决方案是在调用析构函数前声明它。编译器有策略的在某些位置为所有类型生成隐式钩子,以便可以可靠地检测到显式提供的太“晚”的钩子。这个策略源自以下的重写规则:

  • 在构造let/var x = ... (var/let 绑定)中,为typeof(x)生成钩子;
  • x=...(赋值)中,为typeof(x)生成钩子;
  • f(...)(函数调用)中,为typeof(f(...))生成钩子;
  • 对每个sink参数x: sink T,为typeof(x)生成钩子。

{.nodestroy.} 标记

实验性的 {.nodestroy.} pragma 阻止钩子的隐式调用。这可用于特殊的对象遍历,以避免深度递归。

type Node = ref object
	x, y: int32
	left, right: Node

type Tree = object
	root: Node

proc `=destroy`(t: var Tree) {.nodestroy.} =
	# 使用显式的钩子来确保我们不会造成栈溢出:
	var s: seq[Node] = @[t.root]
	while s.len > 0:
		let x = s.pop
		if x.left != nil: s.add(x.left)
		if x.right != nil: s.add(x.right)
		# 显式释放内存:
		dispose(x)
	# 注意:即使's'的析构函数也没有被隐式调用,因此我们还需手动调用它的析构函数:
	`=destroy`(s)

就像这个例子中所述的那样,这个解决方案是不够的,最终应该被一个更好的解决方案所取代。