两年多以前随手写了点与 lock free 相关的笔记:1,2,3,4,质量都不是很高其实(读者见谅),但两年来陆陆续续竟也有些阅读量了(可见剑走偏锋的技巧是多容易吸引眼球)。笔记当中在解决内存释放和 ABA 问题时提到了 Hazard Pointer 这个东西,有两三个读者来信问这是什么,让详细讲一下,我想了想,反正以前在看这东西的时候也记了些东西,干脆整理一下发出来。
前面写的那几篇笔记都来源于 Maged Michael 的学术论文,Hazard pointer 也是他的创想,academic paper 的特点之一就是经常有些美好的假设,关于 hazard pointer 也同样如此,以下的讨论均假设内存模型是 sequential consistent 的,否则还是问题多多。
核心问题
Hazard Pointer(以下简称为 HP) 要解决的核心问题是怎样安全地释放内存,该问题的解决在实现无锁算法时有两个关键的影响:
- 保证了关键节点的访问是合法的,不会导致程序尝试去读取已经释放了的内存。
- 保证了 ABA 问题不会出现,程序逻辑正确的前提。
这两个问题在写无锁代码时基本是无法避免的,走这条路终会遇上,多少人因此费尽心力穷尽技巧各种花样,只为把这问题彻底解决。HP 就是这众多花样各种技巧中的一种,它的做法以我的愚见也不是很完美,但实现上比较简单,不依赖具体系统,也不对硬件有特殊要求(当然 CAS 操作还是要的),从效果上看也凑和,因此无论怎样是值得参考学习的。
具体实现
在无锁算法中释放内存之所以难,主要原因在于,当一个线程准备释放一块内存时,它无法知道是否另有别的线程也同时持有该块内存的指针并需要访问,因此解决这个难点的一个直接想法就是,在每个线程获取了一个关键内存的指针后,该线程将设置一个标志,表明"我正在操作这个关键数据,你们谁都别给我随便就释放了"。当然,这个标志需要放在一个公共区域,使得任何线程都可以去读。当另一个线程想要释放一块内存时,它就去把每个线程的标志都看一下,看看是否有别的线程也在操作这块内存,从而决定是否马上释放该内存:如果有别的线程在操作该内存,则暂时不释放,等下次。具体实现如下:
- 建立一个全局数组 HP hp[N],数组中的元素为指针,称为 Hazard pointer,数组的大小为线程的数目,即每个线程拥有一个 HP。
- 约定每个线程只能修改自己的 HP,而不允许修改别的线程的 HP,但可以去读别的线程的 HP 值。
- 当线程尝试去访问一个关键数据节点时,它得先把该节点的指针赋给自己的 HP,即告诉别人不要释放这个节点。
- 每个线程维护一个私有链表(free list),当该线程准备释放一个节点时,把该节点放入自己的链表中,当链表数目达到一个设定数目 R 后,遍历该链表把能释放的节点通通释放。
- 当一个线程要释放某个节点时,它需要检查全局的 HP 数组,确定如果没有任何一个线程的 HP 值与当前节点的指针相同,则释放之,否则不释放,仍旧把该节点放回自己的链表中。
HP 算法主要用在实现无锁的队列上,因此前面的具体步骤其实基于以下几个假设:
- 队列上的元素任何时候,只可能被其中一个线程成功地从队列上取下来,因此每个线程的 free list 中的元素肯定是唯一的。
- 线程在操作无锁队列时,任何时候基本只需要处理一个节点,因此每个线程只需要一个 HP 就够了,如果有特殊需求,当然 HP 的数目也可以相应扩展。
- 对于某个节点来说,多个线程同时持有该节点的指针这个现象,在时间上是非常短暂有限的,只有当这几个线程同时尝试去取下该节点,它们才可能同时持有该节点的指针,一旦某个线程成功地将节点取下,其它线程很快就会发现,并尝试继续去操作下一下节点,而后续再来取节点的线程则不再可能获得已经不在无琐队列上的节点的指针,因此:当某个线程尝试去检查其它线程的 HP 时,它只需要将 HP 数组遍历一遍就够了,不用担心各线程 HP 值的变化。
以下为我从论文里翻译过来的伪代码,入队列的函数不涉及删除节点因此不会操作 HP,难点都在处理出队列的函数上:
using hp_t = void*;
hp_t hp[N] = {0};
// 以下为队列的头指针。
node_t* top;
data_t* Pop()
{
node_t* t = null;
while (true)
{
t = top;
if (t == null) break;
// 设置当前线程的 HP
hp[this_thread] = t;
// 以下这步是必须的,确认了当前 HP 在 t 被释放前已经被设置到当前线程的 HP 中。
if (t != top) continue;
node_t* next = t->next;
if (CAS(&top, t, next)) break;
}
// 已经不再持有任何节点,需将自己的 HP 设为空.
hp[this_thread] = null;
if (t == null) return null
data_t* data = t->data;
// 尝试释放节点
DeleteNode(t);
return data;
}
以上是出队列的代码,显然,所做的事情非常直白:线程拿到一个节点后将数据取出,并尝试释放节点。释放节点是另一个关键点,具体实现参看如下伪代码:
thread_local vector free_list;
void DeleteNode(node_t* t)
{
free_list.push_back(t);
if (free_list.size() > R) FreeNode();
}
void FreeNode()
{
vector hp_list;
hp_list.reserve(N);
// 获取所有线程的 HP,如非空则保存到 hp_list 中。
for (int i = 0; i < N; ++i)
{
if (hp[i] == null) continue;
hp_list.push_back(hp[i]);
}
std::sort(hp_list);
vector not_free;
not_free.reserve(free_list.size());
// 把当前线程的 free_list 遍历遂一进行释放。
for (int i = 0;i < free_list.size(); ++i)
{
if (std::binary_search(hp_list.begin(), hp_list.end(), free_list[i]))
{
// 某个线程持有当前节点,故不能删除,还是保留在队列里。
not_free.push_back(free_list[i]);
continue;
}
// 确认没有任何线程持有该节点,删除之。
delete free_list[i];
}
free_list.swap(not_free);
}
存在的问题
看到这里相信读者对 Hazard Pointer 的原理已经大概了解了,那么我们来简单总结一下上面的实现。
首先是效率问题,它够快吗?根据前面的伪代码,显然影响效率的关键点在FreeNode()
这个函数上,该函数有一个双重循环,但还好第二重循环用了二分查找,因此删除 R 个节点总的时间效率理论上是 O(R*logN),R 可以设置, N 是线程数目,通常也不会太大,因此均摊下来应该还好?我只能说不知道,看使用场景吧,用无琐一般有很高的效率需求,这里加个这样复杂度的处理是否会比加琐更快呢?也说不准,实现上复杂了是肯定的,想用的话得好好测试测试看看划不划得来。
其次是易用性,HP 释放节点是累进制的,只有当一个线程积累到了一定数量的节点才批量进行释放,而生产环境里通常情况复杂,会不会某个线程积累了几个节点后,就不再去队列里 pop 数据了呢?岂不是总有些节点不能释放?心里有些疙瘩。。除此,现代操作系统里线程创建销毁其实很频繁,某个线程如果要退出了,还得记得把自己头上的节点释放一下,也是件麻烦事。有人可能会觉得为什么删除节点时要把节点放到队列里再删?多此一举!直接遍历 HP 数组直到没有线程持有该节点不就好了 --- 放到队列里其实是为效率,否则每 pop 一次就遍历一遍 HP list,而且搞不好还要反复等待某个线程释放节点,均摊下来效率太低。
最后,还有一个问题,相信读者忍了很久了,HP 数组那里,各个线程怎么 index 进去取出自己的 HP 呢? thread id 吗?那这个数组不得很大很大很大?
一点改进
关于 HP 数组的实现上,作者其实也看到了问题,提出可以用 list 来管理 HP,因为不是每个线程都必须固定分配一个 HP,事实上只有当该线程正在进行 pop 操作的时候它才需要,pop 完了马上就可以把 HP 还回去了,因此数组可以用链表来替换,当然这个链表也得是 Lock free 的,但这个链表可以不用考虑回收和释放实现上容易多了,和我在本系列文章的第四篇里提到的思路是一致的。
但这样用 List 来代替数组在一定程度也增加了效率负担,因为每个线程取出 HP 变得更慢了(首先是很容易引起多个线程冲突,其次用到了 CAS 以及函数调用的开销),当然具体有多少效率损失还得看使用场景,需要好好测量一下---写无琐代码不能少做的事情。
无琐编程很难,但这并不代表它们因此只能是理论游戏,Maged Michael 的无琐系列文章启发了很多人,这其中也包括 c++ 里的大腕 Andrei Alexandrescu,呐呐,看这里。