<Implementing Lock-Free Queues>,Valois在94年写的文章,给出了用链表和数组实现的无互斥操作的FIFO Queue,并基于此给出了解决CAS所存在的ABA问题的方案。这里仅关注该文中的Linked list Implementations部分。
一 概念和背景
Lock-Free数据结构的一个特征是较慢(slow)或者处于停止状态(stopped,例如被tracing的)的线程不会导致其他线程无法访问该数据结构实例。这里的表述需要和Non-blocking algorithm的概念结合起来理解,数据结构的Lock-Free强调对象实例的可访问性,但是Non-blocking algorithm则更强调整体的progress进度。
而之所以选择FIFO Queue进行研究是因为不论是在操作系统核心模块的实现上,以及很多基础核心算法(quicksort,branch-and-bound),还是在分布式任务系统中,Queue这种数据结构都扮演着非常重要的角色。
需要强调的是文章试图实现的是并发队列(concurrent queue)而不是并行队列(parallel queue)。这里的并发和并行和我们通常的理解不是完完全全的对等。从数据结构的角度出发:
1. 多个操作同时进行,但是每个操作都由特定的线程独自负责叫做并发;
2. 多个线程合作一起负责执行同一个操作叫做并行;
对应到FIFO Queue上,就是说一次ENQUEUE或者一次DEQUEUE操作都只由一个确定的线程线性执行。也就是王争提到的“线程安全的数据结构”。
原文对lock-free概念的理解和wiki稍有区别,这里认为lock-free包含两个特征:
- 至少有一个线程能在有限时间内完成自己的工作;实际上严格讲这才是lock-free的概念,就是每一时刻总是有一个线程在工作;
- 进一步的,wait-free性质则保证所有线程都能在有限时间内完成自己的工作,也就是绝对不会存在饥饿线程。
显然wait-free的要求更加的严格,但是不论怎样Lock-based data structure肯定是无法满足任意一点的,因为只要有一个线程持有锁其他线程就都只能傻等。同时作者在这里也强调很多人把no mutual exclusion和Non-blocking给搞混淆了,仅仅无互斥并不一定代表满足以上两个特征。在本文之前,业界通常通过sequential functional算法来实现所谓lock-free数据结构,或者干脆通过lock-based的方式来实现,这些玩儿法的性能都比较差。
另外实现lock-free数据结构的一个前提是系统天然支持FAA和CSW操作。
二 链表实现的lock-free fifo queue
给出的第一版本的入队和出队操作。
// 入队
ENQUEUE(x) {
Node *new_node = new Node;
new_node->value = x;
new_node->next = NULL;
Node *old_tail;
do {
old_tail = TAIL;
if (succ == compare&swap(old_tail->next, NULL, new_node)) break;
compare&swap(TAIL, old_tail, old_tail->next);
} while(1);
compare&swap(TAIL, old_tail, new_node);
}
// 出队
DEQUEUE() {
do {
old_head = HEAD;
if (old_head->next == NULL) return EQEMPTY;
if (succ == compare&swap(HEAD, old_head, old_head->next)) break;
} while(1);
return old_head->next->value;
}
细节上的精髓其实就两个,dummy head和tail position strategy。
2.1 dummy head
所谓的dummy头节点就是说HEAD指针并不指向Q中的第一个合法节点,而是指向上一个出队的节点(last node that dequeued)。这个神操作有两个好处:
- HEAD和TAIL指针永远不会为NULL,这样就不会混淆“空队列”和“只包含一个节点”两种情况,否则无法区分HEAD == TAIL代表的状态;
- 当队列只包含一个节点时,入队和出队操作的竞争被降低了。因为假如没有这个dummy head,当队列只包含一个节点时,入队和出队操作都会涉及到同时操作HEAD和TAIL指针。对于入队,(HEAD , TAIL)二元组的状态转变为(solo_node, solo_node)–> (solo_node, new_node);对于出队,状态变化为(solo_node, solo_node)–> (NULL, NULL);
2.2 tail position strategy
入队操作中line13是算法中非常重要的一步,如果一切都是美好的话(具体一点,所有的线程都不会被意外stopped或者挂掉),算法逻辑本身是不需要line13的这个CSW的,去掉line 13算法也能正常运行。但是实际上line13是实现Non-blocking特性的关键。wiki上对Non-blocking的解释是“任何线程的失败或挂起不会导致另一个线程的失败或挂起”,如果没有line 13行这一性质就无法保证,也就是说line 13直接保证了:即使有线程stopped或者挂掉,其他线程的progress也不会受到影响。
当删除line13时,破坏这一特性的 corner case如下:
step1: A 和 B线程同时开始尝试 ENQUEUE;
step2:B率先成功完成line11,但是B在line 15处因为一些原因stopped(比如被trace)或者被kill掉了而无法执行line16(update TAIL);
此后,A将会不断在while中死循环,因为A拿到的始终是过期的旧TAIL值。
而这里的实现就是通过line13让所有的入队线程都参与到对TAIL的update中来,任一入队线程发现TAIL已经过期都可以尝试去update TAIL到下一个节点。
2.2.1 strict policy for TAIL
在以上的实现中line13通过CSW来进行操作,当线程比较多时这里有非常多的争抢,而且大部分情况下这些争抢都是失败的,因为成功入队的线程B大部分情况下都是能顺利更新TAIL状态的,也就是说其他这些驴推磨非常费驴。于是可以进行一些平衡降低这种消耗。
2.2.2 hint policy for TAIL
一个方法是,Queue的TAIL不一定总是指代Q中的last node,而仅仅是一种“hint”,它实际指向的是一个“非常”接近“last node”的位置。
// 入队
ENQUEUE_with_hint_tail(x) {
Node *new_node = new Node;
new_node->value = x;
new_node->next = NULL;
Node *snapshoot_tail = TAIL;
Node *tmp_tail = snapshoot_tail;
do {
while(tmp_tail->next) tmp_tail = tmp_tail->next;
if (succ == compare&swap(tmp_tail->next, NULL, new_node)) break;
} while(1);
compare&swap(TAIL, snapshoot_tail, new_node);
}
在ENQUEUE_with_hint_tail()中,ENQUEUE()中line 13行的驴推磨被本算法的line 10取代。当某个线程发现当前自己拿到的snapshoot_tail已经不是真正的TAIL时,顺序的从snapshoot开始遍历到最后再继续尝试CSW。通过这种方式避免了ENQUEUE()中大量的无用的CSW消耗,当然,当并发线程数量较多时,遍历链表操作的消耗也会增多,但是至少说在普遍的情况下有效降低了无用功的消耗。
ENQUEUE_with_hint_tail()的一个非常迷惑的细节是snapshoot_tail和tmp_tail的关系。总结出来其实就是一个问题:tmp_tail是否有存在的必要?或者这么问:snapshoot_tail和tmp_tail是否重复?
如果再提炼一下就是:line15是否能改成compare&swap(TAIL, tmp_tail, new_node)?当然是不能的,如果改成compare&swap(TAIL, tmp_tail, new_node),最严重的情况就是TAIL永远都得不到update。请读者自己分析原因。
那么ENQUEUE_with_hint_tail()的“hint TAIL”是怎么来的,仔细分析可以发现在这种实现中并不能保证TAIL随时随地都一定指向last node,而是非常有可能和last node有一定的距离,当然随着并发不断执行,这个距离是大概率会有缩小的趋势的。而产生距离的corner case如下:
- thread A和thread B并发调用ENQUEUE_with_hint_tail()并都获得了相同的snapshoot_tail和tmp_tail;
- 假设其中依然是thread B成功入队,此时Q的状态为 pre_node -> node_b,TAIL == node_b;
- thread A开始进入do-while循环,此时由于thread B已经修改了TAIL,thread A必然需要通过遍历更新tmp_tail,然后thread A也顺利入队,Q状态为pre_node->node_b->node_a, TAIL == node_b;
- thread A试图更新TAIL到node_a,但是本地持有的snapshoot_tail依然为pre_node,与当前TAIL值不同,更新失败,此时TAIL距离真正的last node有一步的差距。
- 此后某一时刻thread C调用ENQUEUE_with_hint_tail(),而C则大概率会修正这个误差;
当然这样完全可以说你这就像庞氏骗局一样,需要一直有人来ENQUEUE否则这就玩儿不下去了。
2.2.3 hint policy的问题
ENQUEUE_with_hint_tail()当然是有问题的,当TAIL在较长一段时间内和真正的last node有一段距离时会带来很大的风险,实际引入了BUG。
特别明显的是,如果thread A没能成功更新TAIL到真正的last node,而之后很长时间都没有出现新的生产者入队来当接盘侠,那么这个hint状态将会非常的危险。因为DEQUEUE()操作只会读写HEAD状态,并不会检查TAIL状态,严重时会导致HEAD超过TAIL,被DEQUEUE的node的资源被回收后TAIL指针基本上就丢失了。但是Valois在文中并没有解决这个问题。