从Linux RCU到EpochGC
开头先说, 这篇文章没有什么中心思想, 只是为了记录一下我对RCU的理解以及思考. 如有错误偏颇, 欢迎评论指正.
RCU(Read-Copy-Update)
RCU是Linux中常用的一种同步机制, 用于读多写少的场景. 它的读取很轻量, 几乎没有什么开销, 但是也有一些限制, 它不保证读取到的是最新的数据, 但是保证读取到的数据是合法的.
其实现原理也很简单, 有点类似于无锁链表的实现.
首先我们知道, CPU可以通过CAS或者类似指令, 实现对于一个64位或者更小数据的原子操作. 如果数据更大一点, 那么就不能直接原子修改掉所有内容, 但是, 我们可以用指针的方式实现.
比如这样
struct Data{
int64_t a;
int64_t b;
int64_t c;
};
static struct Data *data = NULL;
虽然struct Data的大小为24字节, 不能直接原子替换, 但是data作为一个指针, 只有8字节, 对它做原子替换是非常简单的. 所以, 我们只需要让所有需要访问这个结构体的地方, 都使用data指针, 那么不需要锁, 就做到原子地访问struct Data了.
当要修改结构体内容时, 我们可以创建一个新的struct Data, 然后修改它的内容, 最后将data指针原子替换为新的结构体指针, 那么在这次指针修改之后的所有读取, 就会看到新的内容, 也不会有任何数据竞争的问题.
这种通过复制一份新的来修改内容的操作, 就是RCU的"CU", 即Copy-Update.
这也是为什么RCU虽然保证没有数据竞争, 但是不保证读取内容为最新的原因. 假如在写入线程原子替换之前, 读取线程已经拿到了data的旧值并解引用读取数据, 那么是无法看到这次更新的.
像这样通过原子置换指针来修改数据的方式, 在无锁数据结构中还挺常见的, 比如LevelDB的skiplist什么的, bwtree更是如此, 简直是玩出了花.
不过这种方式引入了另一个问题, 假如有一个读取线程正在读取比较旧的数据, 而写线程又在修改数据, 那么写线程就不能释放掉那个老的struct Data, 否则就user-after-free了. 但是又不能不删除, 不然就内存泄露了. 所以需要一种机制, 来找到一个合适的时机, 将那个老struct Data延迟释放掉.
是的, 内核里是不许开GC的...
怎么做呢? 最简单的, 引用计数, 启动! 让struct Data自己存一个计数器, 每当有线程读取它时, 计数器加一, 每当有线程不再读取它时, 计数器减一, 让最后一个把计数器归零的线程释放掉内存就好. 是不是很简单? 但是这里有一个问题, 在众多GC算法中, 引用计数其实开销远远称不上小, 如果每个线程都要修改计数器, 会导致大量的缓存一致性开销, 甚至是内存屏障. 并且, 很显然释放内存通常是读取线程在做, 这会导致读取从一个很轻量的行为变得很重, 不是很优雅.
既然引用计数不行, 那么就换一种方法. Linux的RCU有一个"Grace Period" --- 宽限期的概念. 写入线程在写入后, 会等待一段时间, 确认没有读取线程还在引用旧数据后才释放内存.
但是新的问题来了, 写入线程如何知道旧数据不再被引用了呢? 这就引入了 QSBR 和 Epoch 的概念.
QSBR Quiescent-State Based Reclamation
熟悉Java的同学应该对这个不陌生, 在Java中, 如果线程运行到某个特定位置或者特定区域(Safe Point/Safe Region), 那么此时其引用关系不会发生变化, GC就可以干活了.
C语言当然是没有安全点这个概念了, 不过, 如果所有线程都不在临界区, 那么我们就知道, 现在没有什么被锁保护的数据还被引用着.
这里稍稍多说两句, 为什么是全部线程, 而不需要区分哪些线程用了旧数据, 哪些线程用了新数据呢? 首先是在写入线程之后的所有线程, 它们本身就看不到旧数据的存在, 不用考虑. 而在写入线程之前的线程, 由于其已经退出了临界区, 说明它们已经不在引用旧数据了. 所以这时候我们就可以确认, 此时已经没人知道旧数据的存在了. (随便叫吧, 你就算叫破喉咙也没人知道的, 桀桀桀~)
在Linux 源码中, void call_rcu(struct rcu_head *head, rcu_callback_t func);
就是用来等待这个时机的, 当所有线程都退出临界区后, func回调执行, 释放资源.
或者还有一个,
synchronize_rcu();它会直接阻塞当前调用线程, 直到所有线程都退出临界区. 此时, 旧数据就可以安全释放了. 不过可能延迟会不太好看... 但是会比较省内存, 毕竟你也不想搞出一大波回调来吃掉内存吧. 这两者需要按照实际场景选择合适的.
Epoch GC
在QSBR中, 等待所有线程都退出临界区, 然后释放内存的这个思路非常好, 我们可以顺着这个思路多想一些.
在内核代码中, 在"临界区", 意味着禁止抢占, 禁止中断, 进入了一个"原子"状态.
但用户态程序可没有禁止抢占禁止中断这种特权, 不过我们可以通过一些类似的机制, 来实现类似的效果.
首先, 我们可以维护一个全局的计数器(纪元). 系统定期增加计数器的值, 这个不一定非要基于时间, 也可以某些事件. 然后, 线程自身也维护一个自己的计数器(纪元).
当一个线程需要进入临界区时, 它会先读取当前的全局纪元并更新自身的, 然后进入临界区, 在临界区内, 它会将这个计数器值记录下来, 作为自己的"纪元", 然后开始做一些需要修改共享数据的操作. 在需要释放掉某个对象时, 它会将这个对象与当前纪元关联起来, 并向GC系统标记此对象可回收. 操作结束后(退出临界区), 线程再次更新自己的纪元并通知GC系统.
GC系统会维护一个最老的线程所处的纪元, 当GC系统发现某个对象的纪元比这个最老的纪元还要老时, 那么就可以确认, 这个对象已经没有被任何线程引用了, 然后安全释放.
到这, 恭喜你, 成功发明(误)了一个GC算法, Epoch GC.
当然, 我这里只是一个相对简化的概念讲解, 更详细的可以参考Crossbeam的文档.
GC 与 引用计数
前文提到, 不使用引用计数而是RCU或者Epoch GC是因为引用计数的开销太大. 但其实也不是, 有些场景下, 引用计数是不能用的 ---- 老生常谈的循环引用.
而Epoch GC就没有这个问题, 因为它不是通过引用计数来判断对象是否可回收的, 而是通过纪元来判断. 所以, 它没有循环引用的问题, 也不需要频繁修改对象的引用计数, 从而避免了大量的缓存一致性开销.
End
到这, 本文就差不多结束了. 也不会说什么结束语, 就假装这是一个结尾吧.

浙公网安备 33010602011771号