引用计数 80 岁了。学习一些基本的引用计数知识应该不难。
reference counting is a programming technique of storing the number of references, pointers, or handles to a resource, such as an object, a block of memory, disk space, and others.
在 C++ 里使用引用计数热身一下:
Foo* foo = new Foo();
auto p1 = std::shared_ptr<Foo>(foo);
// 或者干脆
auto p2 = std::make_shared<Foo>();
auto p3 = p1;
p1.use_count(); // 2
RAII 可以帮你在控制块结束时减少 use_count,若为 0 即 delete 之。
使用弱引用避免环引用:
auto p3 = std::weak_ptr<Foo>(p1);
if (p3 == nullptr) {
...
}
在文件系统中,用ln
创建的硬链接是强引用,会改变文件的引用计数,ln -s
软连接是弱引用,不改变计数。
现在我们似乎初步掌握了引用计数,我们用它来解决一个 use-after-free 的问题?
假设我们有一个类,它在另一个线程里引用了自己……
class Foo {
...
Bar* m_bar;
public:
void fancy_multi_threading();
Foo() {
m_bar = new Bar();
}
~Foo() {
delete std::exchange(m_bar, nullptr);
}
};
void Foo::fancy_multi_threading() {
dispatch_async(some_queue, ^(void) {
m_bar->haha(); // 偶尔空指针挂给你看
});
}
...
void f() {
Foo foo;
foo.fancy_multi_threading();
}
然后它就偶尔挂,因为有时 foo 析构了,有时没析构。换言之就是由于多线程程序执行的不确定性,我们在编写代码的时候不知道对象的声明周期了。
解决方案 1: shared_from_this
把析构延迟到所有使用块都结束的时候。这要求 foo 也用 shared_ptr
管理。
class Foo: public std::enable_shared_from_this<Foo> {
...
};
void Foo::fancy_multi_threading() {
auto self = shared_from_this(); // 用引用计数管理生命周期,活了
dispatch_async(some_queue, ^(void) {
self->m_bar->haha();
});
}
...
void f() {
auto foo = std::make_shared<Foo>();
foo->fancy_multi_threading();
}
解决方案 2: 不改变对象的生命周期?
我们可以加一个引用计数的成员 m_alive
标记是否存活,用弱引用的指针指向它,然后在调用 m_bar
之前检查 m_alive
。
但问题是,有可能检查弱引用的时候 this
没析构,检查完 this
就析构了。我们要成功撑过析构这一段,就要上个锁……
class Foo {
...
std::shared_ptr<bool> m_alive; // 和 this 共生死
std::shared_ptr<std::mutex> m_lock; // 可以比 this 多活一会
Foo(): m_alive(new bool(false)), m_lock(new std::mutex()) {
...
}
~Foo() {
m_lock.lock();
m_alive.reset(); // 锁内先释放 m_alive,不然 unlock() 后,析构结束之前可能出事
...
m_lock.unlock();
}
};
void Foo::fancy_multi_threading() {
auto strong_lock = m_lock;
if (strong_lock == nullptr) {
return;
}
// 这时 this 肯定活着
std::weak_ptr<bool> weak_alive = m_alive;
dispatch_async(some_queue, ^(void) {
strong_lock.lock(); // block 复制的 strong_lock 也活着
if (weak_alive != nullptr) { // weak_alive 就不一定了
m_bar->haha();
}
strong_lock.unlock();
});
}
...
void f() { // 这里就不用改了
Foo foo;
foo.fancy_multi_threading();
}
不知道是否真的写对了,性能其实也不怎么样 —— 对, C++ 做自动内存管理的性能比很多面向对象语言差。
Objective-C
我们可以把每个栈帧(函数、作用域)认作一个对象。栈帧上的每个位置(变量名)都是这个对象上的一个 field。和一般对象的 field writer 一样,我们在“变量赋值“处都加上对应的 increase/decrease refcount 的 write barrier (注意:这个是内存管理上下文的 write barrier,和 memory fences 的同名术语不是一个意思)。把作用域结束的释放栈帧对象,那也就要把栈帧上所有对象都 decrease refcount。shared_ptr 好处是重载了赋值运算符,在赋值的时候就能执行 write barrier 代码,而在 Objective-C 的 manual RC 需要手动插入 retain / release 这两个 write barrier,容易出错。所以 OC 添加了 ARC。
ARC 和 C++ 的引用计数的比较:
OC 会把 retain/release 操作生成到 bit code 里,编译器可以做 counting elision 消除部分 retain/release,第一回合 OC 胜。
OC 对象的 header (或者叫 isa)和引用数是压缩在一起的,引用数超出一定 bit 就会存储在 SideTable 里,比 C++ 内存占用少。虽然 decode isa 会占用额外的 CPU cycle 略败,但用一些位运算魔法可以减少操作。第二回合 OC 略胜。
OC 还有 auto release pool:0 引用的对象指针先放到 pool 中,可手动释放(粒度控制),或者在线程 runloop 结束时释放(CPU 空闲时)。Pause 可控,不过 pool 也增加了一点消耗,第三回合 OC 也略胜。
OC 的实现还是比较简单的,下面学习一下其他的引用计数优化/特化方法。
Write Barrier 特化
前面讲 write barrier 可能有的朋友会懵。Write barrier 是一段代码,在 write (设置对象的一个字段)时执行,可以手动调用,可以编译器插入。一般 Write barrier 内可以触发 GC 的一些动作,例如移动对象,释放对象树等。
题外话:Ruby 在添加分代 GC 时就添加了 write barrier,同时给对象添加了 WB_PROTECT 的标记,告诉 write barrier 这个对象不能用 young gen 移动到 old gen。又例如 Golang 是在 write barrier 里做 stack scanning,Golang 1.8 结合了 Djkstra style write barrier (end marking 时停止世界) 和 Yuasa style write barrier (begin marking 时停止世界),实现了世界不停。
在引用计数的系统,编译器可以通过分析,知道某些 write barrier 不会执行某些分支后,就可以插入简单版本的 write barrier 减少分支判断,或者选择性的内联 write barrier 来降低消耗。
Deferred RC
延迟引用计数的想法源于一个观察:栈上对引用来回加减非常频繁,而且经常很浪费。
DRC 的想法概括起来,就是栈帧对象变化时就不做 retain / release 了,而对栈帧做 tracing GC (tracing 仅限于第一层)。栈帧变化时,对被指向的对象添加一个标记 deferred_inc
。没有标记的对象依然做正常的 release / retain。当 deferred_inc
对象引用变 0 的时候不释放,放到 ZCT (zero count table) 中。
在 GC 时,遍历栈帧 root set,更新 ZCT 中的 ref_count,再遍历 ZCT,要么释放要么还掉。
GC 的触发时机:简单办法是分配不出内存时进行 collect (malloc returns NULL)。我们还可以添加别的触发点,例如设置固定的 ZCT 大小,ZCT 满了就触发,或者 IO-wait 时触发,runloop 结束时触发等等。
逻辑如下 (基本复制自 The GC Handbook):
def new sz
p = malloc sz or (collect; malloc sz)
if not p
error "OOM"
end
@zct << p
p
end
atomic def set parent field p # parent != roots
p.rc += 1
@zct.delete p
release_to_zct parent[field]
parent[field] = p
end
atomic def collect
roots.vars -> v, v.rc += 1;
sweep
roots.vars -> v, release_to_zct v;
end
end
def release_to_zct p
if (v.rc -= 1) == 0
@zct << v
end
end
def sweep
while not @zct.empty?
p = @zct.pop
if p.rc == 0
p.fields -> f
if (f.rc -= 1) == 0
@zct.push f
end
end
free p
end
end
end
DRC 号称可以节省 80% 的 reference counting 消耗。但给对象释放时机添加了不确定性。推广开去还有 Ulterior RC, FRC 等,但是都是变相的 GC,那为什么不干脆用 GC 呢?
Linear Logic 和引用计数
Linear Logic 风格的代码就是:栈上每个变量用且只用一次,如果一个变量出现在一个选择支,那它必然出现在另一个选择支。如果最多用一次,就是 Affine Logic。和 SSA 刚好反过来:SSA 是每个变量只定义一次。
写 Linear Logic 相当于手动引用计数。Linear Logic 和 Petri-Net 也有关系,不过就是另一个巨坑了。
Borrowed Pointer 和引用计数
如果一个指针是借来的,那也不用对它进行引用计数。Live Linear Lisp 在运行时对每个指针添加了 anchor bits 记录它是属于哪个栈帧的,如果当前栈帧只是借用对象,就可以 defer 计数操作。
而 Swift 和 LEAN 用编译方法:分析找出哪个参数是 borrowed pointer,然后消除对 borrowed pointer 的引用增减操作。
Immutability 和引用计数
完全 Immutable 的语言,没有环引用,并且也不用处理弱引用,不需承载引用计数的缺点。另外 Immutability 会需要很多复制,但通过判断引用数的变化可以服用现有的内存块进行优化,修正 Immutable 的缺点。
参见 Counting Immutable Beans。
实现一个引用计数系统
假设目标平台 Little endian,64 位。对象 Immutable。
先来设计对象的表示,这里参考了 OC 的 isa,以及胖指针:
#include <stdint.h>
#include <stdboo.h>
// 64bit, 如果长度/类型能在编译期确定,我们就传递 Var
union Var {
// 直接量
bool b;
int64_t i;
double d;
...
// 动态分配的量
void* p; // 指向 VarHeap.data
uint64_t* u; // 更改引用计数可以简化为:v.u[-1]++
};
// 128bit,如果长度/类型编译期未知,我们就传递 FatVar
struct FatVar {
uint64_t size: 40; // 变长对象例如字符串/数组的大小,最大为 2**40 字节的内存
uint64_t cid: 24; // 类型 id
union Var v;
};
// 64bit + sizeof(data)
struct VarHeap {
// 等于 rc - 1,可以初始化为 0
// 由于一个指针至少在堆上占用 16 字节,所以最大的 rc 对应至少 2**36 * 16 = 2**40 字节内存
uint64_t xrc: 36;
uintptr_t alive: 1; // 初始为 1, 当 xrc 减到 -1 时会借位,然后 alive 就 = 0 了
uintptr_t stack: 1; // 由栈分配
uintptr_t arena: 1; // 有内存池分配
uintptr_t f: 1; // 预留,万一哪天我们要实现 DRC 可以用...
uint64_t compressed_cap: 24; // sizeof(data),压缩表示最大为 2**40 字节的内存
char data[];
};
我们如何用 24 位的 compressed_cap 表达范围达 40 位的大小呢?这用一点 bit 魔法来做“有损压缩”。由于我们分配的内存是 8 字节对齐,大小能被 8 字节整除的,所以末三位肯定等于 0。然后我们把末三位利用起来,当作 cap 的指数部分。指数 0/1/2/3/4 分别对应 cap 左移 0/4/8/12/16 位,那最大就能表达到 40 位的大小了。当内存块比较大时 (>=
#define VAR_HEAP_CAP_EXP_MASK 0b111
#define VAR_HEAP_CAP_FRAC_MASK ~((uint64_t)VAR_HEAP_CAP_EXP_MASK)
inline static VarHeap* VAR_HEAP(Var v) {
return (VarHeap)(v.u - 1);
}
// branchless 并且都是 bit op,4 个周期可以解压出来
inline static uint64_t VAR_EXTRACT_CAP(uint64_t compressed_cap) {
int shift = (compressed_cap & VAR_HEAP_CAP_EXP_MASK) << 2; // * 4
return (compressed_cap & VAR_HEAP_CAP_FRAC_MASK) << shift;
}
我们分配的内存块会比需要的大小,适当大一点点,那可以写这么一个笨笨的函数来计算:
#define VAR_HEAP_CAP_FRAC_MAX ((1 << 21) - 1)
#define VAR_MASK(n) ~((1ull << (n)) - 1)
// constexpr
#define VAR_MASK(n) ~((1ull << (n)) - 1)
inline static uint64_t VAR_COMPRESS_CAP(uint64_t size) {
assert(size >= 8);
int shift_i = 0;
if (size <= (VAR_HEAP_CAP_FRAC_MAX << 3)) {
// [[likely]]
size = (size + VAR_MASK(3)) & ~VAR_MASK(3);
} else if (size <= (VAR_HEAP_CAP_FRAC_MAX << 7)) {
shift_i = 1;
size = (size + VAR_MASK(7)) & ~VAR_MASK(7);
} else if (size <= (VAR_HEAP_CAP_FRAC_MAX << 11)) {
shift_i = 2;
size = (size + VAR_MASK(11)) & ~VAR_MASK(11);
} else if (size <= (VAR_HEAP_CAP_FRAC_MAX << 15)) {
shift_i = 3;
size = (size + VAR_MASK(15)) & ~VAR_MASK(15);
} else if (size <= (VAR_HEAP_CAP_FRAC_MAX << 19)) {
shift_i = 4;
size = (size + VAR_MASK(19)) & ~VAR_MASK(19);
} else {
assert(false);
}
return (size >> (shift_i << 2)) | shift_i;
}
对于编译期已知大小的类型,它会折叠成一个常量。对于小于 2**24 的大小,compressed_cap 等于解压后的 cap。
细心的 code reviewer 可能会发现:不是说 Immutable 吗?有 size 了为何还需要保留 capacity ?原因有三:
- 我们有 reset / reuse 的方式重用内存减少重分配,这时就需要动态扩充 buffer。
- 内存不一定是堆上分配的,如果是内存池/栈上分配的话,保留 capacity 大小方便维护。
- 保留尽可能完整的元数据,方便 debug 和做内存分析。
这个表示也有缺点:如果光给一个 heap 指针,不能直接反查出 size 来,一些字符串函数的签名就要长一点了。
好了我们只需要对 heap 对象进行 retain / release 操作。
待续……