2014年(4)
分类: LINUX
2014-01-26 02:26:50
下面给出一个用链表实现的无锁队列
CLMutex是对互斥锁进行的简单封装,用于测试代码,我们主要关注除它以外的其它代码。我首先进行了个宏定义,将CAS操作定义为__sync_bool_compare_and_swap,它是自gcc4.1.0开始支持的一种原子操作,不需要引入任何头文件就可以使用。
先分析队列的插入操作,即上面的函数queuePush。有一个事实需要先交代一下,对于基于链表实现的队列来说,插入操作涉及两个有序子操作:生成新节点并将老队尾指针的next域设成此节点和将此节点作为新队尾指针,相信这个大家都很容易理解。无锁数据结构有一个一般性的实现原则就是先把“该准备的都准备好,然后公布结果”。对应于这里,我们要先把节点生成出来,该填的字段填好,然后再进行关键的“公布”操作。“公布”操作之所以复杂是因为对于非原子性操作我们不知道什么时候会有其它线程抢占CPU执行,我们需要采取措施来保证非原子操作在并发环境中的一致性和正确性。废话少说,直接上代码,进入while循环,我们获取队列尾指针,也获取此尾指针的next域,然后判断刚刚获取的队尾指针是不是最新值,如果不是最新的,说明有别的线程已经完成了插入操作,我们不能在“过时”的队尾后插入数据,否则就会覆盖掉人家已经插入的节点,所以continue下面的步骤,重新获取队尾指针及其next域的最新值来进行插入操作。如果获取的是最新的队尾指针,要去判断其next域是否为空,为空,走正常流程,不为空,就更新队尾指针并重新来过。这里为什么要判断队尾指针next域是否为空需要简单说明一下,其实上面已经给出了答案,因为插入操作是由更新老队尾指针next域和更新队尾指针两个有序子操作完成的,即便队尾指针是最新的,但是说明不了没有其它线程已经更新了next,如果人家更新了next,当前线程也不能进行插入操作,道理同上,转而“助人为乐”,帮其更新队尾指针。过了这两关,说明你有利用新节点更新队尾指针next域的资格了,但事情还没算完,如果更新next域成功,那线程才可以去更新队尾指针完成整个插入操作,否则只能说明当前线程”人品“不好,陪人家过了前两关但还是不能进行插入,只好再重新来过。这就是基于链表实现的无锁队列的插入操作,很烦吧,没办法,这个世界是公平的,你提升了性能,减少了运行时开销,就必然要增加实现的逻辑难度。
我们继续说无锁队列的删除操作。队列的删除都是基于头部进行的,所以我们获取队列头指针及其next域,但这里为什么还要获取队尾指针呢?我稍后再解释。我们出队列操作都是基于head->next,而不是head本身,这样考虑是由于一个边界条件:如果队列只有一个元素,head和tail指针可能指向同一个节点,这样插入(进队列)操作和删除(出队列)操作本身就需要互斥了,通过引入一个dummy头指针来解决这个问题。如下图所示:
先解释一下三个指针:
(1)WriteIndex:一个新元素即将插入的位置,即生产者即将生产的位置。
(2)ReadIndex:下一个可读取元素位置,消费者即将进行消费的位置。
(3)MaxReadIndex:相当于一个“哨兵”,读取指针ReadIndex只能落后于(因为是循环数组,不能说小于)该指针。MaxReadIndex可能等于也可能落后于WriteIndex,这是由于队列的插入操作存在预取的性质,对应程序的第64行,如果预取成功则赋给那个位置新元素,然后更新MaxReadIndex位置,将新元素发布出去,完成整个插入操作。上面的程序很简单,唯一需要解释一下的就是第74行,sched_yield函数的作用是让当前线程主动让出处理器,这么做的原因是生产者需要预留生产位置进行生产,因此在更新MaxReadIndex时我们需要按顺序更新,否则前面的预留位置没赋给新元素(相当于生产者还没在这个位置生产),但后面的预留位置被赋值就会导致MaxReadIndex被更新,让消费者误认为MaxReadIndex之前元素都已经被生产好,因此如果发现更新不是按顺序发生就放弃处理器给别的线程。如果还没理解可以参照下面示意图:
一个线程只插入一个元素:
(1)插入前状态:
(2)预留一个位置,移动WriteIndex位置:
(3)插入一个元素,移动MaxReadIndex发布出去
再举个插入的例子,两个线程各插入一个元素:
(1)插入前状态:
(2)两个线程都预留出了生产位置
(3)一个线程完成了插入操作后:
(4)另一个线程也完成了插入操作:
删除元素:
(1)删除前状态:
(2)某线程消费一个元素后:
(3)某线程又消费了一个元素后:
这时,ReadIndex和MaxReadIndex相等了,说明队列为空,不能继续消费。
(全文完)