无锁编程_无锁数据结构

无锁编程_无锁数据结构无锁编程 / lock-free / 非阻塞同步 无锁编程,即不使用锁的情况下实现多线程之间的变量同步,也就是在没有线程被阻塞的情况下实现变量的同步,所以也叫非阻塞同步(Non-blocking Synchronization)。 实现非阻塞同步的方案称为“无锁编程算法”( Non-blo

无锁编程_无锁数据结构"

无锁编程 / lock-free / 非阻塞同步

无锁编程,即不使用锁的情况下实现多线程之间的变量同步,也就是在没有线程被阻塞的情况下实现变量的同步,所以也叫非阻塞同步(Non-blocking Synchronization)。

实现非阻塞同步的方案称为“无锁编程算法”( Non-blocking algorithm)。

lock-free是目前最常见的无锁编程的实现级别(一共三种级别)。

为什么要 Non-blocking sync ?

使用lock实现线程同步有很多缺点:

* 产生竞争时,线程被阻塞等待,无法做到线程实时响应。

* dead lock。

* live lock。

* 优先级翻转。

* 使用不当,造成性能下降。

 

如果在不使用 lock 的情况下,实现变量同步,那就会避免很多问题。虽然目前来看,无锁编程并不能替代 lock。

实现级别

非同步阻塞的实现可以分成三个级别:wait-free/lock-free/obstruction-free。

 

wait-free

是最理想的模式,整个操作保证每个线程在有限步骤下完成。

保证系统级吞吐(system-wide throughput)以及无线程饥饿。

截止2011年,没有多少具体的实现。即使实现了,也需要依赖于具体CPU。

 

lock-free

允许个别线程饥饿,但保证系统级吞吐。

确保至少有一个线程能够继续执行。

wait-free的算法必定也是lock-free的。

 

obstruction-free

在任何时间点,一个线程被隔离为一个事务进行执行(其他线程suspended),并且在有限步骤内完成。在执行过程中,一旦发现数据被修改(采用时间戳、版本号),则回滚。

也叫做乐观锁,即乐观并发控制(OOC)。事务的过程是:1读取,并写时间戳;2准备写入,版本校验;3校验通过则写入,校验不通过,则回滚。

lock-free必定是obstruction-free的。

CAS原语

LL/SC, atom read-modify-write

如果CPU提供了Load-Link/Store-Conditional(LL/SC)这对指令,则就可以轻松实现变量的CPU级别无锁同步。

LL [addr],dst:从内存[addr]处读取值到dst。

SC value,[addr]:对于当前线程,自从上次的LL动作后内存值没有改变,就更新成新值。

上述过程就是实现lock-free的 read-modify-write 的原子操作。

 

CAS (Compare-And-Swap)

LL/SC这对CPU指令没有实现,那么就需要寻找其他算法,比如CAS。

CAS是一组原语指令,用来实现多线程下的变量同步。

在 x86 下的指令CMPXCHG实现了CAS,前置LOCK既可以达到原子性操作。截止2013,大部分多核处理器均支持CAS。

CAS原语有三个参数,内存地址,期望值,新值。如果内存地址的值==期望值,表示该值未修改,此时可以修改成新值。否则表示修改失败,返回false,由用户决定后续操作。

 

  1.  
    Bool CAS(T* addr, T expected, T newValue)

  2.  
    {

  3.  
    if( *addr == expected )

  4.  
    {

  5.  
    *addr = newValue;

  6.  
    return true;

  7.  
    }

  8.  
    else

  9.  
    return false;

  10.  
    }

 

 

ABA 问题

thread1意图对val=1进行操作变成2,cas(*val,1,2)。

thread1先读取val=1;thread1被抢占(preempted),让thread2运行。

thread2 修改val=3,又修改回1。

thread1继续执行,发现期望值与“原值”(其实被修改过了)相同,完成CAS操作。

 

使用CAS会造成ABA问题,特别是在使用指针操作一些并发数据结构时。

 

解决方案

ABAʹ:添加额外的标记用来指示是否被修改。

 

 

所谓lock-free和wait-free算法是指对于共享的数据并非对其加锁来控制访问,而是多个线程并行的访问。通过该算法可以达到对共享对象并发的读写而不会破坏对象本身。所谓lock-free是指对于线程不加锁,让系统执行所有的步骤。lock-free提到的不加锁是指不使用类似于互斥锁或者信号量之类的排他机制。因为一旦对线程加锁的话,当线程执行中断时,那么对于这个系统来说运行也中断了。所谓wait-free是指,不管其他线程执行什么操作,线程无论有什么操作都能在有限的步骤里面完成。所以对于算法来说达到lock-free不一定能达到wait-free,但是达到wait-free的算法一定是lock-free的。

意义

在多线程编程中,对于共享资源的访问最传统的做法就是加锁。互斥锁和信号量本质上都是在代码层面的某一段逻辑上加上排他机制,从而达到对于共享资源的访问不造成破坏性的结果。假如某个线程需要获得已经被其他线程先占有的锁,那么在那个锁释放之前,这个线程的工作会陷入停止状态。
很多情况下,我们都不希望看到线程的运行停止。首先,阻塞中的线程无法做任何事情。其次,如果线程要处理的事务优先级很高乃至要实时处理的话,我们也不希望看到线程被阻塞。再者,当多个资源被锁的时候,就容易出现死锁、活锁或者优先顺序颠倒等问题。最后,使用锁的地方,如果对加锁的逻辑颗粒度很大的话会导致并行处理的机会会减少,如果加锁颗粒度太细又容易产生bug而不得不小心设计,最后陷入死胡同。

wait-free的数据结构

使用wait-free的数据结构的应用程序中,与其将原来使用互斥的算法改造为wait-free的算法,不如直接使用基于wait-free算法开发的stack、queue、set和map。例如,在Java 5以后,java.util.concurrent包中就引入了wait-free的数据结构。通过直接使用这些wait-free的数据结构,编写线程的异步数据访问也将变得很容易。

举个银行存钱的例子

例如,在银行的柜台有个存钱的程序。每个线程相当于一个ATM。当金钱存入的时候,需要将当前余额读出来,然后加上要存入的金额算出新的余额。如果通过锁来实现的话,当一台ATM在计算的时候,为了让其他ATM不能同时变更余额,需要加锁。否则的话,如果同时更新将导致数据错误。如果通过lock-free来实现的话,需要一个管理所有存入请求的独立线程,然后创建一个wait-free的队列。所有ATM异步的将存入金额的请求放入队列中而无需加任何的锁。管理所有存入请求的独立线程从队列中依次取出请求,更新账户余额。通过以上方式,无需单独实现lock-free的存钱算法,编程也更加便捷。同时,该方法因为队列是wait-free的,所以不仅仅实现了lock-free也实现了wait-free。对于余额的更新如果需要N并发的话,只需要创建N个wait-free的队列,然后根据账号对N取余放入对应队列中即可。

CAS Compare and Swap

在实现lock-free和wait-free算法时,需要CPU专用的管理指令来完成。其中最重要的就是Compare and Swap(简称CAS)。在Java中,在java.util.concurrent.atomic包中类方法compareAndSet来实现。其中使用到了内存地址、旧值和新值。如果该地址所保存的值和旧值相同则替换为新值,如果不是的话则什么都不做。然后将处理成功与否的结果返回。所以需要CPU支持该方法。目前Intel的芯片中有该功能。通过该功能实现了从内存中读出数据,进行更新后写入的时候其他线程无法同时更新的算法。
继续拿前面的银行柜台来举例,我们换个算法来实现。ATM将当前余额读出、计算再写入这三个步骤可以类比为CPU的CAP操作。进行这三个步骤的时候,没有其他线程更新该值的话则认为操作成功。如果在进行这三步的时候,第一步读出的金额和第三步更新要用新值去替换的金额不一致的情况下,命令直接失败,然后操作回滚到第一步重新执行。所有的ATM都是遵循这个方法,在成功之前都反复执行这三步。这个算法是lock-free的但是不是wait-free的。因为当其他ATM进行操作的时候,会影响当前ATM的操作,导致可能要反复执行步骤。

ABA问题

CAS实现的过程是先取出内存中某时刻的数据,在下一时刻比较并替换。

  1. 1号线程从内存位置 α 中取出值 A
  2. 2号线程也从内存位置 α 中取出值 A
  3. 2号线程进行了一些操作将 α 位置的数据从 A 变成了 B
  4. 2号线程又进行了一些操作将 α 位置的数据变成A
  5. 这时候1号线程进行 CAS 操作发现内存中仍然是A,然后操作成功。

尽管1号线程的CAS操作成功,但是不代表这个过程就是没有问题的。
我们来看个具体例子:
现有一个用单向链表实现的堆栈,栈顶为A,这时线程T1已经知道A.next为B:
head→A→B
后希望用CAS将栈顶替换为B

head.compareAndSet(A,B);

在T1执行上面这条指令之前,线程T2介入,将A、B出栈,再pushD、C、A,此时堆栈结构如下,而对象B此时处于游离状态:
head→A→C→D
B
此时轮到线程T1执行CAS操作,检测发现栈顶仍为A,所以CAS成功,栈顶变为B,但实际上B.next为null,所以此时的情况变为:
head→B
A→C→D
其中堆栈中只有B一个元素,C和D组成的链表不再存在于堆栈中,平白无故就把C、D丢掉了。

AtomicStampedReference/AtomicMarkableReference

以上就是由于ABA问题带来的隐患,各种乐观锁的实现中通常都会用版本戳version来对记录或对象标记,避免并发操作带来的问题,在Java中,AtomicStampedReference<E>也实现了这个作用,它通过包装[E,Integer]的元组来对对象标记版本戳stamp,从而避免ABA问题。
下面的代码用AtomicStampedReference来对初始值为100的原子整型变量进行更新,而加上版本戳的AtomicStampedReference对于ABA问题会执行CAS失败:

import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicStampedReference; public class ABA { private static AtomicStampedReference<Integer> atomicStampedRef = new AtomicStampedReference<Integer>(100, 0); public static void main(String[] args) throws InterruptedException { Thread refT1 = new Thread(new Runnable() { @Override public void run() { try { TimeUnit.SECONDS.sleep(1); } catch (InterruptedException e) { e.printStackTrace(); } atomicStampedRef.compareAndSet(100, 101, atomicStampedRef.getStamp(), atomicStampedRef.getStamp()+1); atomicStampedRef.compareAndSet(101, 100, atomicStampedRef.getStamp(), atomicStampedRef.getStamp()+1); } }); Thread refT2 = new Thread(new Runnable() { @Override public void run() { int stamp = atomicStampedRef.getStamp(); System.out.println("before sleep : stamp = " + stamp); // stamp = 0 try { TimeUnit.SECONDS.sleep(2); } catch (InterruptedException e) { e.printStackTrace(); } System.out.println("after sleep : stamp = " + atomicStampedRef.getStamp());//stamp = 2 boolean c3 = atomicStampedRef.compareAndSet(100, 101, stamp, stamp+1); System.out.println(c3); //false } }); refT1.start(); refT2.start(); } }

今天的文章无锁编程_无锁数据结构分享到此就结束了,感谢您的阅读,如果确实帮到您,您可以动动手指转发给其他人。

版权声明:本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌侵权/违法违规的内容, 请发送邮件至 举报,一经查实,本站将立刻删除。
如需转载请保留出处:https://bianchenghao.cn/48480.html

(0)
编程小号编程小号
上一篇 2023-09-03 23:17
下一篇 2023-09-03

相关推荐

发表回复

您的电子邮箱地址不会被公开。 必填项已用*标注