<Implementing Lock-Free Queues> -- Linked list Implementations

<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包含两个特征:

  1. 至少有一个线程能在有限时间内完成自己的工作;实际上严格讲这才是lock-free的概念,就是每一时刻总是有一个线程在工作;
  2. 进一步的,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如下:

  1. thread A和thread B并发调用ENQUEUE_with_hint_tail()并都获得了相同的snapshoot_tail和tmp_tail;
  2. 假设其中依然是thread B成功入队,此时Q的状态为 pre_node -> node_b,TAIL == node_b;
  3. thread A开始进入do-while循环,此时由于thread B已经修改了TAIL,thread A必然需要通过遍历更新tmp_tail,然后thread A也顺利入队,Q状态为pre_node->node_b->node_a, TAIL == node_b;
  4. thread A试图更新TAIL到node_a,但是本地持有的snapshoot_tail依然为pre_node,与当前TAIL值不同,更新失败,此时TAIL距离真正的last node有一步的差距。
  5. 此后某一时刻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在文中并没有解决这个问题。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值