C++:07.无锁数据结构

看了很多博客,大多讲的很高深,看起来很费劲,能力有限只能简单总结一下。

什么是无锁数据结构:

先说一下锁是干嘛的,在多线程环境下,由于很多操作不是原子操作,导致多个线程同时做了一个工作,为了防止这种情况的发生,我们通过对执行代码前上锁,让其他进程无法执行该步骤,再执行完后解锁,其他线程才能完成该步骤。

再说一下为什么要无锁数据结构:上锁解锁的过程是很消耗资源的,因为要从用户态切到内核态。简单的步骤加大量的锁会造成频繁切入切出,为了解决这一自相矛盾的问题,就有了无锁数据结构。

举个栗子:

比如像++count(count是整型变量)这样的简单操作也得加锁,因为即便是增量操作,实际上也是分三步进行的:读、改、写。

movl x, %eax

addl $1, %eax

movl %eax, x

只要再写入之前,切换了另一个进程,另一个进程完成了写入的操作。再切回来,原进程还是会继续执行,最后的结果导致+2。

为了解决这种问题,我们第一时间肯定想到了加上互斥锁控制同一时刻只能有一个线程可以对队列进行写操作,但是加锁的操作太消耗系统资源了。因为对临界区的操作只有一步 就是对队列的尾节点进行更新,所以只要让这一步进行的是原子操作就可以了。解决方法就使用到了CAS操作。

原子操作(了解):

原子操作的实现机制,是在硬件层面上,CPU会默认保证基本的内存操作的原子性,CPU保证从系统内存当中读取或者写入一个字节的行为肯定是原子的,当一个CPU读取一个字节时,其他CPU处理器不能访问这个字节的内存地址。但是对于复杂的内存操作CPU处理器不能自动保证其原子性,比如跨总线宽度或者跨多个缓存行(Cache Line),跨页表的访问等。这个时候就需要用到CPU指令集中设计的原子操作指令,现在大部分CPU指令集都会支持一系列的原子操作。而在无锁编程中经常用到的原子操作是Read-Modify-Write  (RMW)这种类型的,这其中最常用的原子操作又是 COMPARE AND SWAP(CAS),几乎所有的CPU指令集都支持CAS的原子操作。

CAS操作:

CAS:Compare and Swap, 比较并交换。

CAS 靠CPU硬件实现。 通过总线枷锁的方式。该进程没执行完,其他指令无法使用总线。

伪代码如下:
bool CAS( int * pAddr, int nOld, int nNew )
{
    if ( *pAddr == nOld )   如果pAddr地址中值还等于原先的nOld
    {
        *pAddr = nNew ;   那么将 nNew 的值赋给此变量,
        return true ;     并返回true;
    }
    else  否则说明pAddr中的值已经不是nOld中的值了,那就不交换了。
    {
        return false ;
    }    
}

CAS所有执行过程都是原子性的、不可分的,不会产生任何可见的部分结果。

当然上面这个返回的是bool。如果想知道之前内存单元中的当前值是多少,改下返回值即可。

int CAS( int * pAddr, int nOld, int nNew )
{
    if ( *pAddr == nOld ) 
    {
        *pAddr = nNew ;
        return nOld;
    }
    else
    {
        return *pAddr;
    }   
}

 在gcc中提供了对应的这俩个函数:

bool __sync_bool_compare_and_swap (type *ptr, type oldval, type newval, ...)
type __sync_val_compare_and_swap (type *ptr, type oldval, type newval, ...)

举第二个栗子:

多线程环境下, 对同一链队列进行入队操作时,一个线程A正在将新的队列节点挂载到队尾节点的next上,可是还没来的及更新队尾节点,同一时刻另一个线程B也在进行入队操作将新的队列节点也挂在到了没更新的队尾节点那么A线程挂载的节点就丢失了。

EnQueue(x) //入队列方法
{ 
    q = new record();
    q->value = x; //队列节点的值
    q->next = NULL;//下一个节点
    p = tail; //保存尾节点指针
    oldp = p;
    do { //开始 loop  cas
         while (p->next != NULL) //用来防止进行cas(tail,oldp,q)操作的线程挂掉引起死循环
            p = p->next;
    } while( CAS(p.next, NULL, q) != TRUE); 
CAS(tail, oldp, q); 
}

DeQueue() //出队列方法
{
    do{
        p = head;
        if (p->next == NULL)
        {
            return ERR_EMPTY_QUEUE;
        }
    }while( CAS(head, p, p->next) != TRUE );
    return p->next->value;
}

具体实现:

gcc从4.1.2提供了__sync_*系列的built-in函数,用于提供加减和逻辑运算的原子操作。

其声明如下:

原子操作的 后置加加:
type __sync_fetch_and_add (type *ptr, type value, ...)

原子操作的 前置加加:
type __sync_add_and_fetch (type *ptr, type value, ...)

其他类比
type __sync_fetch_and_sub (type *ptr, type value, ...)
type __sync_fetch_and_or (type *ptr, type value, ...)
type __sync_fetch_and_and (type *ptr, type value, ...)
type __sync_fetch_and_xor (type *ptr, type value, ...)
type __sync_fetch_and_nand (type *ptr, type value, ...)


type __sync_sub_and_fetch (type *ptr, type value, ...)
type __sync_or_and_fetch (type *ptr, type value, ...)
type __sync_and_and_fetch (type *ptr, type value, ...)
type __sync_xor_and_fetch (type *ptr, type value, ...)
type __sync_nand_and_fetch (type *ptr, type value, ...)

这两组函数的区别在于第一组返回更新前的值,第二组返回更新后的值。

CAS缺点:

1、ABA问题:

因为CAS需要在操作值的时候检查下值有没有发生变化,如果没有发生变化则更新,但是如果一个值原来是A,变成了B,又变成了A,那么使用CAS进行检查时会发现它的值没有发生变化,但是实际上却变化了。

ABA问题的解决思路就是使用版本号。在变量前面追加上版本号,每次变量更新的时候把版本号加一,那么A-B-A 就会变成1A - 2B-3A。

2、循环时间长开销大:

自旋CAS如果长时间不成功,会给CPU带来非常大的执行开销。

3、多个共享变量操作时,CAS无法保证操作的原子性。

只能保证一个共享变量的原子操作对一个共享变量执行操作时,我们可以使用循环CAS的方式来保证原子操作,但是对多个共享变量操作时,循环CAS就无法保证操作的原子性,这个时候就可以用锁,或者有一个取巧的办法,就是把多个共享变量合并成一个共享变量来操作。比如有两个共享变量i=2,j=a,合并一下ij=2a,然后用CAS来操作ij。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章