前言
之前写了一个 [多线程] C++ 手写线程池的设计,其中使用了队列。由于设计多线程,用互斥锁和条件变量来管理队列元素的添加和取出。这是一种有锁队列的实现方式。
在有锁队列之上,还有无锁队列的存在 。无锁队列是一种高并发的高性能组件,一些库也实现了高性能队列。
我还没有用到过无锁队列,但是有必要初步理解它的原理。
以下部分主要参考:
酷 壳 – COOLSHELL
我这里相当于一个阅读笔记,以我的理解记录下来,便于以后查阅。
什么是无锁队列
我们知道为了避免线程竞争,需要对普通队列加锁。那么可不可以不加锁,来避免线程竞争呢?
有,就是无锁队列。无锁队列用原子操作替代了锁。原子操作确保不会出现线程竞争和死锁。
无锁队列的原子操作
无所队列的原子操作指的是利用系统提供的原子操作接口来实现原子性。
我看到原子操作第一反是C++中的Atomic模板,它提供了CAS API。
参考:
C++原子变量atomic详解
cplusplus.com/reference/atomic/
CAS
CAS操作——Compare & Set,或是 Compare & Swap,现在几乎所有的CPU指令都支持CAS的原子操作,X86下对应的是 CMPXCHG 汇编指令。有了这个原子操作,我们就可以用其来实现各种无锁(lock free)的数据结构。
——————
这个操作用C语言来描述就是下面这个样子:(代码来自Wikipedia的Compare And Swap词条)意思就是说,看一看内存reg里的值是不是oldval,如果是的话,则对其赋值newval。
int compare_and_swap (int reg, int oldval, int newval)
{
int old_reg_val = *reg;
if (old_reg_val == oldval) {
*reg = newval;
}
return old_reg_val;
}
我们可以看到,old_reg_val 总是返回,于是,我们可以在 compare_and_swap 操作之后对其进行测试,以查看它是否与 oldval相匹配,因为它可能有所不同,这意味着另一个并发线程已成功地竞争到 compare_and_swap 并成功将 reg 值从 oldval 更改为别的值了。
——————
https://coolshell.cn/articles/8239.html
这个操作可以变种为返回bool值的形式(返回 bool值的好处在于,可以调用者知道有没有更新成功):
bool compare_and_swap (int *addr, int oldval, int newval)
{
if ( *addr != oldval ) {
return false;
}
*addr = newval;
return true;
}
——————
https://coolshell.cn/articles/8239.html
我们需要清楚,在编程时,CAS接口是系统提供给我们的,我们通过上面的C代码,大概理解它的参数和返回值即可,我们不应该也不知道系统的接口具体是如何实现的。
我们在使用CAS接口的时候,只需要知道,如果没有成功替换(即*reg实际的值,和我们传入的用于比较的oldval不一致),就是有别的线程在我们调用CAS接口之前访问过reg地址了,此时reg中的内容已经不在我们掌控中了。
我们需要重新获取oldval,然后再执行CAS接口,不断循环,直到替换成功。
不同环境中提供的CAS接口:
1)GCC的CAS
GCC4.1+版本中支持CAS的原子操作(完整的原子操作可参看 GCC Atomic Builtins)
——————
https://coolshell.cn/articles/8239.html
bool __sync_bool_compare_and_swap (type *ptr, type oldval type newval, ...)
type __sync_val_compare_and_swap (type *ptr, type oldval type newval, ...)
2)Windows的CAS
在Windows下,你可以使用下面的Windows API来完成CAS:(完整的Windows原子操作可参看MSDN的InterLocked Functions)
——————
https://coolshell.cn/articles/8239.html
InterlockedCompareExchange ( __inout LONG volatile *Target,
__in LONG Exchange,
__in LONG Comperand);
- C++11中的CAS
C++11中的STL中的atomic类的函数可以让你跨平台。(完整的C++11的原子操作可参看 Atomic Operation Library)
——————
https://coolshell.cn/articles/8239.html
template< class T >
bool atomic_compare_exchange_weak( std::atomic* obj,
T* expected, T desired );
template< class T >
bool atomic_compare_exchange_strong( volatile std::atomic* obj,
T* expected, T desired )
无锁队列的实现
无所队列可以通过链表或者数组实现。
链表
初始化链表:
InitQueue(Q)
{
node = new node()
node->next = NULL;
Q->head = Q->tail = node;
}
——————
https://coolshell.cn/articles/8239.html
入队
代码 1:
EnQueue(Q, data) //进队列
{
//准备新加入的结点数据
n = new node();
n->value = data;
n->next = NULL;
do {
p = Q->tail; //取链表尾指针的快照
} while( CAS(p->next, NULL, n) != TRUE);
//while条件注释:如果没有把结点链在尾指针上,再试
CAS(Q->tail, p, n); //置尾结点 tail = n;
}
——————
https://coolshell.cn/articles/8239.html
No1, } while( CAS(p->next, NULL, n) != TRUE);
首先这里模拟的是,文章中所说的返回bool值的CAS API。
p是获取的对位指针,当p-> == NULL 时,证明此时p是队尾。
这里所作的是:
如果p是队尾,就把n赋值给p->next;
如果p不是队尾,继续循环,在循环中重新获取p。
No2,CAS(Q->tail, p, n); //置尾结点 tail = n;
如果tail指针, 指向的还是和p一样,就让tail指向n。
此处是变更tail指针的指向,此时,tail==p;p->next==n;n是最末尾的元素,需要让tail指向n。
这两个CAS操作,完成了向队尾添加元素,变更tail指针的操作。
在第二个CAS没有完成前,tail->next 始终不是NULL,所以别的线程无法通过第一个CAS函数的校验。因此,这两个CAS函数之间的代码,就是当前线程独占的,利用CAS完成了入队的操作,还能在这里做点需要做的事。
代码 2:
EnQueue(Q, data) //进队列改良版 v2
{
n = new node();
n->value = data;
n->next = NULL;
while(TRUE) {
//先取一下尾指针和尾指针的next
tail = Q->tail;
next = tail->next;
//如果尾指针已经被移动了,则重新开始
if ( tail != Q->tail ) continue;
//如果尾指针的 next 不为NULL,则 fetch 全局尾指针到next
if ( next != NULL ) {
CAS(Q->tail, tail, next);
continue;
}
//如果加入结点成功,则退出
if ( CAS(tail->next, next, n) == TRUE ) break;
}
CAS(Q->tail, tail, n); //置尾结点
}
——————
>上述的代码还是很清楚的,相信你一定能看懂,而且,这也是 Java 中的 ConcurrentLinkedQueue 的实现逻辑
—————
https://coolshell.cn/articles/8239.html
按照文章,这里避免了一个问题,如果当前线程通过了No1 CAS,但是在执行NO2 CAS之前出现了意外,比如崩溃了,那不是别的线程永远获取不到队列权限了,出现了死锁。
所以,通过No3 CAS(Q->tail, tail, next);
让tail时刻指向最后一个元素;
然后,No4 if ( CAS(tail->next, next, n) == TRUE ) break;
加入节点,加入成功就退出循环;
退出循环后,No5 CAS(Q->tail, tail, n); //置尾结点
更新tail。
这里就不像,上面No1和NO2 CAS之间,有线程独占的代码区域,不用担心别的线程打扰。
No3和No5哪里更新tail都没关系,也能看到根本没有判断No3和No5有没有执行成功,因为没有必要判断,如果当前线程执行了No4 CAS后,别的线程执行了NO3,那当前线程No5就会失败,失败了也无所谓。
出队
代码 1:
DeQueue(Q) //出队列
{
do{
p = Q->head;
if (p->next == NULL){
return ERR_EMPTY_QUEUE;
}
while( CAS(Q->head, p, p->next) != TRUE );
return p->next->value;
}
——————
https://coolshell.cn/articles/8239.html
p = Q->head;
if (p->next == NULL){
return ERR_EMPTY_QUEUE;
}
这里是因为,Q->head 首先指向一个dummy元素,空的,不用于存储数据。
p == Q->head == dummy node;
p->next == dummy->next,才开始指向队列元素。
如果p->next == NULL,说明队列是空的,无法取出元素了。
while( CAS(Q->head, p, p->next) != TRUE );
我感觉这句代码,pop的是dummy node。
虽然返回了第一个节点的值,但是也把head指向第一个节点了。
不过代码1就是作为一个示例,本身在`` if (p->next == NULL){````部分也存在当只有一个节点时,enqueue()和dequeue()可能存在竞争的问题。
所以有了代码2。
代码 2:
DeQueue(Q) //出队列,改进版
{
while(TRUE) {
//取出头指针,尾指针,和第一个元素的指针
head = Q->head;
tail = Q->tail;
next = head->next;
// Q->head 指针已移动,重新取 head指针
if ( head != Q->head ) continue;
// 如果是空队列
if ( head == tail && next == NULL ) {
return ERR_EMPTY_QUEUE;
}
//如果 tail 指针落后了
if ( head == tail && next == NULL ) {
CAS(Q->tail, tail, next);
continue;
}
//移动 head 指针成功后,取出数据
if ( CAS( Q->head, head, next) == TRUE){
value = next->value;
break;
}
}
free(head); //释放老的dummy结点
return value;
}
——————
https://coolshell.cn/articles/8239.html
//如果 tail 指针落后了 if ( head == tail && next == NULL ) {
这里判断条件我觉得应该是: if ( head == tail && next != NULL ) 。
这里的逻辑其实和enqueue差不多的。在移动head之前,判断是否为空队列,并通过CAS来判断和移动tail指针的位置。 避免了head和tail指向同一个节点,然后操作的情况。
free(head); //释放老的dummy结点
这里证明确认抛弃了dummy节点。
这个代码实现的逻辑是,如果一个队列有 dummy - node1 - node2 这些节点,
执行一次dequeue()后,获得的返回值是node1->value, 队列的head也指向node1了。
原文中说DeQueue的代码操作的是 head->next
,可能就是对这个问题的解释。head->next才是第一个节点。(我觉得这样有点奇怪,可能是我还没理解这样的好处。)
无锁队列的风险
ABA问题
所谓ABA(见维基百科的ABA词条),问题基本是这个样子:
进程P1在共享变量中读到值为A
P1被抢占了,进程P2执行
P2把共享变量里的值从A改成了B,再改回到A,此时被P1抢占。
P1回来看到共享变量里的值没有被改变,于是继续执行。
虽然P1以为变量值没有改变,继续执行了,但是这个会引发一些潜在的问题。ABA问题最容易发生在lock free 的算法中的,CAS首当其冲,因为CAS判断的是指针的值。很明显,值是很容易又变成原样的。
以下引用对无锁队列的ABA问题说的更清楚:
线程A | 线程B | 队列 |
---|---|---|
head = A(0x114), newhead = B(0x514) | A(0x114)->B(0x514) Head = A(0x114) | |
出队A, delete A | ||
出队B, delete B | ||
new C(0x114), 入队C | ||
new D(0x810), 入队D | C(0x114)->D(0x810) Head = C(0x114) | |
CAS(Head, head, newhead)通过 | ? |
来源:https://zhuanlan.zhihu.com/p/352723264
如上面表格,线程A获取了head的指针(指向地址0x114)。
在进行CAS之前,线程B获取了权限了,并且出队了节点,又入队的节点,发生了内存重新分配,导致新的头和head指向的地址一样。
这时候CAS判断是可以通过的,但是设置完的队列节点内容是什么就不知道了。
对策
double-CAS
对于链表型的无所队列可以用double-CAS。
Double CAS的思想是为地址增加引用计数,使用双倍大小的头指针,在原指针后附加一个计数器,每次出队时将计数器加一。这样即使出现ABA问题,由于计数器对不上,CAS也就不会通过了。
>before
using AtomicWord = intptr_t;
struct AtomicNode
{
volatile AtomicWord _next;
void* data;
};
>after
// 必须16字节对齐,否则_InterlockedCompareExchange128会报错
struct alignas(16) AtomicWord2
{
AtomicWord lo, hi;
};
class AtomicQueue
{
volatile AtomicWord _tail;
volatile AtomicWord2 _head;
......
}
参考:https://zhuanlan.zhihu.com/p/352723264
常量优化
我们一直在判断_head 是否等于head,而head最初也是等于_head的。编译器并不知道可能有另外的线程在修改_head的值,因此可能会将_head与head的比较优化掉,只从内存中读取一次_head的值存放进寄存器,随后便一直使用寄存器中的数据,使得我们的自旋等待失效。这便是常量优化。
常量优化的原因是寄存器的读写速度远高于内存,编译器会减少从内存读取数据的次数。而volatile关键字就是告诉编译器,不要对这个变量进行常量优化,每次都去内存中读取。
——————
https://zhuanlan.zhihu.com/p/352723264
根据以上说法,为了避免常量优化,我们应该对节点中的next指针变量,添加volatile关键字。
数组无锁队列
如果采用数组形式的无锁队列(环形数组:RingBuffer),就不需要进行节点内存的申请和释放,就避免了内存重新分配了,也就不需要Double CAS了。
我们可以看到链表的无锁队列一定要避免ABA的问题,还是有BUG风险的,所以我看到一般推荐使用数组无锁队列。只是数组的元素数量是有限的。
数组无锁队列一般使用环形数组,这个细节以后有机会继续研究。
参考:https://blog.csdn.net/gengzhikui1992/article/details/88422604