无锁队列 再看 酷 壳 – COOLSHELL 转载

CAS-Compare & Set,或是 Compare & Swap

现在cpu 都支持cas原子操作了, 比如x86下 对应的是CMPXCHG汇编指令有了它 我们来看下各种无锁队列数据结构

这个操作用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;
}

这个操作可以变种为返回bool值的形式,这样调用者知道有没有更新成功

bool compare_and_swap (int *addr, int oldval, int newval)
{
  if ( *addr != oldval ) {
      return false;
  }
  *addr = newval;
  return true;
}

 

入队列用CAS实现的方式

  1. 第一步,把tail指针的next指向要加入的结点。 tail->next = p;
  2. 第二步,把tail指针移到队尾。 tail = p;
复制代码
EnQueue(Q, data) //入队列
{
    //准备新加入的结点数据
    n = new node();
    n->value = data;
    n->next = NULL;
    
    do {
        p = Q->tail; //取链表尾指针的快照
    } while( CAS(p->next, NULL, n) != TRUE); 
    //---  如果 p->next 是 NULL,那么,把新结点 n 加到队尾 否则重新来一次
//在准备在队列尾加入结点时,别的线程已经加成功了,于是tail指针就变了,于是我的CAS返回了false,于是程序再试,直到试成功为止

    CAS(Q->tail, p, n); //置尾结点 tail = n;  没必要 是用 循环判断cas 是否成功原因:
/*
1.如果有一个线程T1,它的while中的CAS如果成功的话,那么其它所有的 随后线程的CAS都会失败,然后就会再循环,
2.此时,如果T1 线程还没有更新tail指针,其它的线程继续失败,因为tail->next不是NULL了。
3.直到T1线程更新完 tail 指针,于是其它的线程中的某个线程就可以得到新的 tail 指针,继续往下走了。
4.所以,只要线程能从 while 循环中退出来,意味着,它已经“独占”了,tail 指针必然可以被更新。
*/

}
复制代码

上述存在一个问题:如果T1线程在用CAS更新tail指针的之前,线程停掉或是挂掉了,那么其它线程就进入死循环了

复制代码
EnQueue(Q, data) //进队列改良版 v1
{
    n = new node();
    n->value = data;
    n->next = NULL;

    p = Q->tail;
    oldp = p
    do {
        while (p->next != NULL)
            p = p->next;
    } while( CAS(p.next, NULL, n) != TRUE); //如果没有把结点链在尾上,再试
while (p->next != NULL) {
p = p->next;
}
    do { CAS(Q->tail, oldp, n)} while(xxx); //置尾结点 -----需要考虑cas 是否成功
}
复制代码

每个线程,自己fetch 指针 p 到链表尾。但是这样的fetch会很影响性能。同时,如果一个线程不断的input,会导致所有的其它线程都去 fetch 他们的 p 指针到队尾,

能不能不要所有的线程都干同一个事?

A:由于所有的线程都共享着 Q->tail;一旦有人动了它后,相当于其它的线程也跟着动了

复制代码
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); //置尾结点
}
复制代码

 

出队:

复制代码
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;
}
复制代码

DeQueue的代码操作的是 head->next,而不是 head 本身。

如果 head 和 tail 都指向同一个结点,这意味着队列为空,应该返回 ERR_EMPTY_QUEUE,但是,在判断 p->next == NULL 时,另外一个EnQueue操作做了一半,此时的 p->next 不为 NULL了,但是 tail 指针还差最后一步,没有更新到新加的结点,这个时候就会出现,在 EnQueue 并没有完成的时候, DeQueue 已经把新增加的结点给取走了,此时,队列为空,但是,head 与 tail 并没有指向同一个结点;出现如下场景:

 

 

 

 

也就是在 出队和入队同时操作时, 各种同步场景需要考虑,所以需要判断各种临界条件

复制代码
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结点  这里的free应该是换成减少引用计数的put操作,当引用计数为0时,才可以真正的释放资源。 毕竟多线没有完全互斥, 有可能另一个线程还在使用, 所以不能直接free  必须使用引用计数
    return value;
}
复制代码

参考  转载  https://coolshell.cn/articles/8239.html

 CAS 缺点:

1、循环时间长开销大:CAS操作失败,就需要循环进行CAS操作

2、只能保证一个共享变量的原子操作

3、CAS 整体操作不能保证原子操作完成, 所以最后出现ABA

ABA问题

CAS:对于内存中的某一个值V,提供一个旧值A和一个新值B。如果提供的旧值V和A相等就把B写入V。这个过程是原子性的。

ABA:如果另一个线程修改V值假设原来是A,先修改成B,再修改回成A。当前线程的CAS操作无法分辨当前V值是否发生过变化。

 解决ABA的问题方法:double-CAS/在变量前面加上版本号/引用计数 等方法

 

 

解决ABA的问题

 

维基百科上给了一个解——使用double-CAS(双保险的CAS),例如,在32位系统上,我们要检查64位的内容

 

1)一次用CAS检查双倍长度的值,前半部是指针,后半部分是一个计数器。

 

2)只有这两个都一样,才算通过检查,要吧赋新的值。并把计数器累加1。

 

这样一来,ABA发生时,虽然值一样,但是计数器就不一样(但是在32位的系统上,这个计数器会溢出回来又从1开始的,这还是会有ABA的问题)

 

当然,我们这个队列的问题就是不想让那个内存重用,这样明确的业务问题比较好解决,论文《Implementing Lock-Free Queues》给出一这么一个方法——使用结点内存引用计数refcnt

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
SafeRead(q)
{
    loop:
        p = q->next;
        if (p == NULL){
            return p;
        }
 
        Fetch&Add(p->refcnt, 1);
 
        if (p == q->next){
            return p;
        }else{
            Release(p);
        }
    goto loop;
}

 

其中的 Fetch&Add和Release分是是加引用计数和减引用计数,都是原子操作,这样就可以阻止内存被回收了。

 

posted @   codestacklinuxer  阅读(146)  评论(0编辑  收藏  举报
(评论功能已被禁用)
编辑推荐:
· SQL Server 2025 AI相关能力初探
· Linux系列:如何用 C#调用 C方法造成内存泄露
· AI与.NET技术实操系列(二):开始使用ML.NET
· 记一次.NET内存居高不下排查解决与启示
· 探究高空视频全景AR技术的实现原理
阅读排行:
· 阿里最新开源QwQ-32B,效果媲美deepseek-r1满血版,部署成本又又又降低了!
· SQL Server 2025 AI相关能力初探
· AI编程工具终极对决:字节Trae VS Cursor,谁才是开发者新宠?
· 开源Multi-agent AI智能体框架aevatar.ai,欢迎大家贡献代码
· Manus重磅发布:全球首款通用AI代理技术深度解析与实战指南
点击右上角即可分享
微信分享提示