王者并发课-星耀3:自在不羁-领会非阻塞的同步机制和算法

欢迎来到《王者并发课》,本文是该系列文章中的第29篇,星耀中的第3篇

众所周知,在驾车经过拥堵路段时,我们会经常面对:排队等待或者绕道而行。前者可以少走弯路,而后者则可以节约时间,它们各有利弊。同样的,在软件设计中,我们也会面临类似的并发问题。因此,阻塞还是非阻塞,就成了我们在处理这类问题时的两种常见方案。

在前面的系列文章中,我们主要介绍的多是阻塞方案,比如同步队列就是典型的阻塞解决方案。然而,阻塞方案虽然可以让整体更为有序,但会降低整体的性能,不利于最大程度地使用资源。毕竟,等待是对时间的浪费。所以,在本文中,我们将通过示例来讨论处理并发的另外一种方案:非阻塞的机制和算法实现

一、阻塞带来的麻烦

我们仍然以峡谷医院的就诊为例来说明阻塞带来的麻烦。

早晨八点整,峡谷的牛大夫开始上班。刚一落座,铠捷足先登成了她今天的第一个病人。随后,子龙在八点半到达医院,可是这时候铠正在就诊,所以他只能等待。于是,铠磨磨唧唧和医生从八点聊到了九点,子龙也就从八点半等到了九点。注意,在这半个小时中,子龙除了等待无法做其他的事

在这个过程中,我们可以理解由于铠没有及时释放资源,子龙被阻塞了。我们试想下,如果此情此景出现在软件设计中会发生什么情况?小部分线程对资源长时间占据,将导致大量线程被阻塞,从而导致系统陷入瘫痪的状态。如果你对此感到陌生,那也许是你还没有遇到过线程池被打满的场景。

image-20220606173214051

二、非阻塞的利与弊

既然,在某些场景下,阻塞将导致系统瘫痪,那有没有办法解决呢?当然有,并且我们会自然而然地会想到非阻塞。比如,在上面的示例中,假如子龙并没有始终在等待,而是他每隔几分钟去了解下情况。如果医生恰好有空,那他可以直接去就诊,否则他可以做些其他的事情,比如掏出电脑写两行代码。

这就是非阻塞,当前线程在获取资源失败时,不会原地等待,而是直接返回并通过轮询等方式不断尝试。这样的好处显而易见,可以降低系统的负载,并提高线程资源的利用情况。

image-20220606173229618

非阻塞算法是软件设计中的常见算法,也是一种能高性能解决高并发的方案,它主要通过使用底层的原子机器指令来代替锁,从而保证数据在并发中的一致性。作为无锁方案,非阻塞方案在可伸缩性和线程的调度上拥有较大的优势,由于没有阻塞所以没有复杂的调度开销。同时,非阻塞算法也不存在死锁和其他线程状态管理问题。

当然,凡事都有两面性,有一利必有一弊,而非阻塞算法的弊端则在于设计和实现起来很复杂

三、如何实现非阻塞设计

(一)非阻塞的基础:CAS

在设计和实现非阻塞算法时,通常会根据CAS来实现,也就是Compare and Swap(简称CAS),这是一种CPU底层提供的计算能力。 CAS的核心在于,当更新一个变量时,只有这个变量的旧值和内存中的值相同时,才会执行更新。它是一个原子操作,也就是说数据的读取和更新是在一起的。

举个例子,子龙和铠都从内存中读取x=5,随后他们俩分别对x进行了更新:铠将值从5变更为8,即CAS(5,8);而子龙则将试图将x从5变更为9,即CAS(5,9) 。那么,子龙能成功吗?当然不能

因为x的值已经发生了变化。当子龙拿着旧值5去试图将x设置为9时,x的值已经不再等于5这就是CAS的要义,要更新可以,但要和以前一样才行

image-20220607204000345

(二)CAS的基础:volatile变量

在前面的JAVA内存模型文章中,我们详细讲述了volatile变量的作用,如果你对其不甚了解可以查阅相关章节。简而言之,volatile可以让变量的值在变化时对其他线程可见。也就是说,线程在读取变量时,始终从主存读取而不是缓存,从而保障读取的数据都是最新的。

我们知道,CAS的核心在于更新变量时会比较当前变量的最新值,所以CAS读取的变量必然需要最新的,所以这个变量需要是volatile类型。比如,AtomicInteger是JAVA非阻塞设计的典型,它的内部用于计数的value字段便是volatile类型,相关核心源码如下所示。

根据下面的源码,我们可以清楚地看到AtomicInteger内部有个compareAndSet方法,这是它所提供的CAS方法。注意看,compareAndSet内部调用的则是Unsafe类所提供的compareAndSwapInt方法。sun.misc.Unsafe是个比较底层的方法,它提供了一些列的和硬件层面交互的能力,关于Unsafe我们不需要做深入的了解,在工作中也应尽量避免对它的直接使用。当然,如果你对它有兴趣,可以参考这篇文章了解更多。

public class AtomicInteger extends Number implements java.io.Serializable {

    // setup to use Unsafe.compareAndSwapInt for updates
    private static final Unsafe unsafe = Unsafe.getUnsafe();
    private static final long valueOffset;

    private volatile int value;

    public AtomicInteger(int initialValue) {
        value = initialValue;
    }

    public final boolean compareAndSet(int expect, int update) {
        return unsafe.compareAndSwapInt(this, valueOffset, expect, update);
    }

    public final int incrementAndGet() {
        return unsafe.getAndAddInt(this, valueOffset, 1) + 1;
    }
}

sun.misc.Unsafe中对底层方法的调用:

public final int getAndAddInt(Object var1, long var2, int var4) {
    int var5;
    do {
        var5 = this.getIntVolatile(var1, var2);
    } while (!this.compareAndSwapInt(var1, var2, var5, var5 + var4));

    return var5;
}

(三)非阻塞算法的应用

为了直观感受非阻塞方法和阻塞方法在使用时的异同,我们仍然以前面文章的就诊作为示例。在就诊时,每个医生同时只允许一个病人前往就诊,其他的病人需要排队等候。于是,我们通过synchronized来模拟这个场景,相关源码如下所示。由于synchronized的修饰,diagnosis方法是阻塞的,未获得同步锁的线程将处于阻塞状态。

/**
 * 当前是否可以就诊
 */
private volatile boolean isAvailable;

public synchronized void diagnosis() {
    try {
        isAvailable = true;
        // ... 就诊中
    } finally {
        isAvailable = false;// 就诊结束离开后,释放资格。
    }
}

现在,我们将上述示例代码由阻塞改为非阻塞,如下源码所示。注意,我们将控制就诊状态的变量由volatile boolean isAvailable变更为AtomicBoolean isAtomicAvailable,并且diagnosis方法没有再使用synchronized修饰。

重点在于while循环中的条件控制逻辑。和阻塞算法明显不同的是,非阻塞算法在抢占失败时,不会进入等待状态,而是不断地尝试直至成功。

/**
 * 当前是否可以就诊
 */
private final AtomicBoolean isAtomicAvailable = new AtomicBoolean();

public void diagnosis() {
    try {
        while (isAtomicAvailable.compareAndSet(false, true)) {
            // ... 就诊中
        }
    } finally {
        isAtomicAvailable.set(false);// 就诊结束离开后,释放资格。
    }
}

AtomicInteger只是一个典型的非阻塞算法的示例。在Java中的java.util.concurrent.atomic包中,有大量类似的AtomicXXX工具,它们长相略有不同但原理类似,比如AtomicBooleanAtomicLongAtomicIntegerArray等。借助于这些工具,可以帮助我们很方便地实现各种非阻塞的原子性操作。

四、ABA问题与破解

虽然CAS足够强大且易用,但并不意味着它完美无缺。对于老道的程序员来说,A-B-A问题一定耳熟能详。那什么是A-B-A问题?

我们知道,CAS在计算时,会计算传入的期望值和现有的内存值是否一致,如果不一致则拒绝计算。那么,假如内存值从A变成B再变回A时,其他线程是否知道?比如,铠和子龙同时拿到了x=5,但铠闲得无聊把x的值从5改成8,随后又从8改成了5。问题在于,铠这么牛气冲天,子龙知道吗? 毕竟当子龙进行CAS(5,9)操作时,看起来似乎没有变化,而且还不会出错。

但是,我们看到的是x的值其实已经发生了变化,这就是经典的A-B-A问题

image-20220607195922421

对于A-B-A问题,如何解决?比较简单有效的办法是增加版本号,Java中提供了AtomicStampedReference来解决这一问题。

小结

正文到此结束,恭喜你又上了一颗星✨

在本文中,我们讲解了非阻塞算法的来龙去脉,以及在JAVA中的应用。读完本文,应当理解的是在处理并发问题时,阻塞并不是唯一的解决办法。在综合考虑性能和数据安全的前提下,不要忘记我们还有非阻塞的方案可以选择。在某些场景下,非阻塞可能是更优的方案。

虽然非阻塞的优势明显,但同时我们也要理解的是非阻塞在设计和实现上具有一定的复杂度,而且通常都是基于CAS方案实现。在Java中,CAS由底层组件提供以实现和硬件的交互,并且需要结合volatile类型的字段。当然,JUC中提供了丰富的Atomic类型工具,在需要使用非阻塞方案时,应当首先考虑这些既有的成熟方案。

夫子的试炼

  • 查阅AtomicStampedReference源码,了解其实现思想、原理并应用。

延伸阅读与参考资料

常见面试题

  • 说说自己对 非阻塞算法的理解?
  • 非阻塞算法中的volatile字段有什么作用?
  • 如何理解并解决ABA问题?

关于作者

专注高并发领域创作。人气专栏《王者并发课》、小册《高并发秒杀的设计精要与实现》作者,关注公众号【MetaThoughts】,及时获取文章更新和文稿。


如果本文对你有帮助,欢迎点赞关注监督,我们一起从青铜到王者

posted @ 2022-06-13 19:59  秦二爷  阅读(126)  评论(0编辑  收藏  举报