引用oc文件_Hello, 引用计数

引用计数 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 操作。

待续……

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值