欢迎来到《王者并发课》,本文是该系列文章中的第29篇,星耀中的第3篇。
众所周知,在驾车经过拥堵路段时,我们会经常面对:排队等待或者绕道而行。前者可以少走弯路,而后者则可以节约时间,它们各有利弊。同样的,在软件设计中,我们也会面临类似的并发问题。因此,阻塞还是非阻塞,就成了我们在处理这类问题时的两种常见方案。
在前面的系列文章中,我们主要介绍的多是阻塞方案,比如同步队列就是典型的阻塞解决方案。然而,阻塞方案虽然可以让整体更为有序,但会降低整体的性能,不利于最大程度地使用资源。毕竟,等待是对时间的浪费。所以,在本文中,我们将通过示例来讨论处理并发的另外一种方案:非阻塞的机制和算法实现。
一、阻塞带来的麻烦
我们仍然以峡谷医院的就诊为例来说明阻塞带来的麻烦。
早晨八点整,峡谷的牛大夫开始上班。刚一落座,铠捷足先登成了她今天的第一个病人。随后,子龙在八点半到达医院,可是这时候铠正在就诊,所以他只能等待。于是,铠磨磨唧唧和医生从八点聊到了九点,子龙也就从八点半等到了九点。注意,在这半个小时中,子龙除了等待无法做其他的事。
在这个过程中,我们可以理解由于铠没有及时释放资源,子龙被阻塞了。我们试想下,如果此情此景出现在软件设计中会发生什么情况?小部分线程对资源长时间占据,将导致大量线程被阻塞,从而导致系统陷入瘫痪的状态。如果你对此感到陌生,那也许是你还没有遇到过线程池被打满的场景。
二、非阻塞的利与弊
既然,在某些场景下,阻塞将导致系统瘫痪,那有没有办法解决呢?当然有,并且我们会自然而然地会想到非阻塞。比如,在上面的示例中,假如子龙并没有始终在等待,而是他每隔几分钟去了解下情况。如果医生恰好有空,那他可以直接去就诊,否则他可以做些其他的事情,比如掏出电脑写两行代码。
这就是非阻塞,当前线程在获取资源失败时,不会原地等待,而是直接返回并通过轮询等方式不断尝试。这样的好处显而易见,可以降低系统的负载,并提高线程资源的利用情况。
非阻塞算法是软件设计中的常见算法,也是一种能高性能解决高并发的方案,它主要通过使用底层的原子机器指令来代替锁,从而保证数据在并发中的一致性。作为无锁方案,非阻塞方案在可伸缩性和线程的调度上拥有较大的优势,由于没有阻塞所以没有复杂的调度开销。同时,非阻塞算法也不存在死锁和其他线程状态管理问题。
当然,凡事都有两面性,有一利必有一弊,而非阻塞算法的弊端则在于设计和实现起来很复杂。
三、如何实现非阻塞设计
(一)非阻塞的基础: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的要义,要更新可以,但要和以前一样才行。
(二)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工具,它们长相略有不同但原理类似,比如AtomicBoolean、AtomicLong和AtomicIntegerArray等。借助于这些工具,可以帮助我们很方便地实现各种非阻塞的原子性操作。
四、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问题。
对于A-B-A问题,如何解决?比较简单有效的办法是增加版本号,Java中提供了AtomicStampedReference来解决这一问题。
小结
正文到此结束,恭喜你又上了一颗星✨
在本文中,我们讲解了非阻塞算法的来龙去脉,以及在JAVA中的应用。读完本文,应当理解的是在处理并发问题时,阻塞并不是唯一的解决办法。在综合考虑性能和数据安全的前提下,不要忘记我们还有非阻塞的方案可以选择。在某些场景下,非阻塞可能是更优的方案。
虽然非阻塞的优势明显,但同时我们也要理解的是非阻塞在设计和实现上具有一定的复杂度,而且通常都是基于CAS方案实现。在Java中,CAS由底层组件提供以实现和硬件的交互,并且需要结合volatile
类型的字段。当然,JUC中提供了丰富的Atomic类型工具,在需要使用非阻塞方案时,应当首先考虑这些既有的成熟方案。
夫子的试炼
- 查阅AtomicStampedReference源码,了解其实现思想、原理并应用。
延伸阅读与参考资料
常见面试题
- 说说自己对 非阻塞算法的理解?
- 非阻塞算法中的
volatile
字段有什么作用? - 如何理解并解决ABA问题?
关于作者
专注高并发领域创作。人气专栏《王者并发课》、小册《高并发秒杀的设计精要与实现》作者,关注公众号【MetaThoughts】,及时获取文章更新和文稿。
如果本文对你有帮助,欢迎点赞、关注、监督,我们一起从青铜到王者。