Btree并发内存回收
在并发写Btree原理剖析 一文中提到,节点内存回收有可能导致内存突增以及影响写性能。本文将阐述最近对内存回收的改进,多线程可并行回收内存。
回收策略
采用基于版本的机制,Btree全局维护一个版本号V,一个全局数组A,每个线程一个槽位,每个线程读写操作之前获取全局版本号V,记作V0,并且记录到全局数组A中自己所属的槽位,表明对应线程不会再访问版本号V0之前的节点。写操作结束后,获取全局版本号V记录到这次写操作的TRecycleNode中,然后将V原子加1,这样来推进Btree的版本号递增,最后将这个TRecycleNode挂在线程局部的TRecycleNode的链表尾部。回收内存时,只需要遍历全局数组A,找到最小版本号记作minV,每个线程只回收线程局部的版本号小于minV的所有的TRecycleNode及其内部的TBtreeNode。可以看出,在目前这种回收策略中,不存在全局的TRecycleNode链表,线程不用抢锁,各自回收自己局部内存即可。
这里有一个问题:如果某个线程由于某些原因,很少对Btree进行读写,那么全局数组A的最小值永远不变,导致其他线程也不能回收内存。针对这种情况,可以在每次读写操作结束后,将线程在全局数组中对应的槽位置为无穷大。
这就带来了另外一个问题:一个线程刚拿到全局版本号V,记作V0,还没有来得及放入全局数组A对应槽位,随后一些写操作完成全局版本号V被递增,然后某个线程开始回收,扫描了全局数组并且已经计算出了最小版本V1,这时V1 > V0, 代表V0可以被回收,这时,第一个线程读V0就会出现问题。解决这个问题,可以使用类似于hazard pointer的机制,读写开始获取版本号和获取回收版本号的过程分别如下:
读写开始拿可用版本号的过程:
1. 拿全局版本号V放入局部变量V0: V0 <- V
2. 将V0写入全局数组中对应的槽位中: A[slot] <- V0
3. 如果 V0 = V 则可以开始安全的读写,版本号为V0,否则重试,跳第一步。
写线程回收时拿到可回收版本号的过程:
1. 拿全局版本号V放入局部变量V1: V1 <- V
2. V2 <- min(A)
3. V3 <- min(V1,V2) V3之前的版本即为可以回收的版本
证明:
1. 如果V1 <= V0:则V3 = min(V1,V2) <= V1 <= V0,读写拿到的版本号V0 大于等于回收版本号
2. 如果V1 > V0:如果读写过程的第三步成立V0 = V,那么,回收过程的第二步V2 <- min(A)计算最小值时已经看到了读写过程写入全局数组A中的版本V0,即V3 = min(V1,V2) <= V0
可以看出,这种方式对于正常的Btree读写流程来说,可能第3步需要进行重试: 在第一步和第三步之间的最多的几十ns期间全局版本号V被其它写线程递增。当然,这种情况还算是比较少。为了进一步的避免这个问题,可以采用如下方法:
维护一个全局可回收版本号recycleV,它比V小一些,比如100个版本,每个线程回收时只会回收小于recycleV的TRecycleNode,读写过程和回收过程算法如下:
读写开始拿可用版本号的过程:
1. 拿全局版本号V放入局部变量V0: V0 <- V
2. 将V0写入全局数组中对应的槽位中: A[slot] <- V0
3. 如果 V0 > recycleV 则可以开始安全的读写,版本号为V0,否则重试,跳第一步。
写线程回收时拿到可回收版本号的过程:
1. recycleV <- V – 100 多线程需要保证recycleV递增
2. V2 <- min(A)
3. V3 <- min(recycleV,V2) 小于V3的版本即为可以回收的版本
这种优化相当于让回收线程滞后100个版本回收。
还有其它一些小优化,比如全局数组A每个元素cache line对齐,避免false sharing,线程局部可用节点太多,归还一些到全局内存池等。
分析
从读写操作开始拿版本号的过程可以看出,实际上这种方法就是hazard pointer !! 看这篇:lock free数据结构内存回收技术-hazard pointer
读写开始拿可用版本号的过程:
1. 拿全局版本号V放入局部变量V0: V0 <- V
2. 将V0写入全局数组中对应的槽位中: A[slot] <- V0 // 这一步相当于将pointer给保护起来
3. 如果 V0 > recycleV 则可以开始安全的读写,版本号为V0,否则重试,跳第一步。 // 这一步相当于check一下上一步是否保护成功
测试结果
在2路超线程32core机器上,起32个线程,int64_t作为KV,TPS由原来的120W左右提升到240W左右,QPS由原来的680W左右提升到1900W左右。