像宝石一样的Java原子类-基于CAS实现

写在前面

原文作者是Java语言的架构师Brian Goetz,他也是Lambda项目的主导者,著有经典并发书《Java Concurrency in Practice》。这是一篇发表于2004年的古老文章了,在JDK5刚刚发布之后,作为Java布道者第一时间对JDK5的新特性做了很透彻的说明。
十几年过去了,Java的一些实现细节或许发生了变化,但是万变不离其宗,最基础的理论是不会变的。做为译文,我英语能力受限,翻译的可能有不准确的地方,敬请指出。如果可以的话,请一定要耐心读一下原文(原文链接见最后)。

本文主要内容

1. 线程同步标准的处理方法:上锁
2. 锁的问题
3. 硬件同步原语CAS
4. 使用CAS实现计数器
5. Lock-free和 wait-free 算法
6. Atomic原子变量类

十五年前,多处理器系统是高度专业化的系统,通常耗资数十万美元(其中大多数具有两到四个处理器)。
如今,多处理器系统既便宜又丰富,几乎主流的微处理器都内置了对多处理器的支持,很多能够支持数十或数百个处理器。
为了充分利用多处理器系统的性能,通常使用多个线程来构建应用程序。
但是,仅仅把工作分散在多个线程中不足以充分利用硬件的性能,你必须保证你的线程大部分时间都在工作,而不是在等待工作或者在等待共享数据上的锁。那么如何协调多线程并发的工作呢?

问题:线程之间的协作

很少有应用可以不依赖线程协作而实现真正的并行化。
例如一个线程池,其中的任务通常是彼此独立的被执行,互不干扰。一般会使用一个工作队列来维护这些任务,那么从工作队列中删除任务或向其中添加任务的过程必须是线程安全的,这意味着需要协调队列头部、尾部、以及节点之间的链接指针。这种协调工作是麻烦的根源。

标准的处理方法:上锁

在Java中,协调多线程访问共享变量的传统方式是同步
通过同步(synchronized关键字)可以保证只有持有锁的线程才可以访问共享变量,且具有独占访问权,线程对共享变量的改变对于其他线程也是立即可见的。
同步的缺点是,当锁的竞争激烈时(多个线程频繁的尝试获取锁),吞吐量会受到影响,同步的代价会非常高。
基于锁的算法另一个问题是如果一个持有锁的线程被延迟(由于page fault、调度延迟、或其他异常),那么其他正在等待该锁的线程都将无法执行。
volatile变量也可以用于存储共享变量,其成本比synchronized要低。但是它有局限性,虽然volatile变量的修改对其他线程是立即可见的,但是它无法呈现原子操作的read-modify-write操作序列,
这意味着,volatile变量无法实现可靠的互斥锁或计数器

用锁实现计数器和互斥体

考虑开发一个线程安全的计数器类,该类公开get()、increment()和decrement()操作。
所有方法,甚至get(),都是同步的,以保证不会丢失任何更新,并且所有线程都可以看到计数器的最新值。

Listing 1. A synchronized counter class
public class SynchronizedCounter {
    private int value;
    public synchronized int getValue() { return value; }
    public synchronized int increment() { return ++value; }
    public synchronized int decrement() { return --value; }
}

其中increment() 和 decrement()都是原子的read-modify-write操作,为了安全的递增计数器,你必须取出当前值,然后对它加1,最后再把新值写回。所有这些操作都将作为一个单独的操作完成,中途不能被其他线程打断。
否则,如果两个线程同时进行increment操作,意外的操作交错会导致计数器只被递增了一次,而不是两次。(请注意,通过把变量设置为volatile,不能可靠的实现以上操作)
原子的read-modify-write组合操作出现在很多并发算法中。下面的代码实现了一个简单的互斥体(Mutex,Mutual exclusion的简写)。acquire()方法就是原子的read-modify-write操作。
要获取这个互斥体,你必须确保没有其他线程占用它(curOwner==null),成功获取后标识你已经持有该锁(curOwner = Thread.currentThread()),这样其他线程就不可能再进入并修改curOwner变量。

Listing 2. A synchronized mutex class
public class SynchronizedMutex {
    private Thread curOwner = null; 
    public synchronized void acquire() throws InterruptedException {
        if (Thread.interrupted()) throw new InterruptedException();
        while (curOwner != null) 
            wait();
        curOwner = Thread.currentThread();
    }
    public synchronized void release() {
        if (curOwner == Thread.currentThread()) {
            curOwner = null;
            notify();
        } else
            throw new IllegalStateException("not owner of mutex");
    }
}

清单1中的计数器类在没有竞争或竞争很少的情况下可以可靠的工作。
然而,在竞争激烈时性能将大幅下降,因为JVM将花费更多的时间处理线程调度以及管理竞争,而花费较少的时间进行实际工作,如增加计数器。

锁的问题

如果一个线程尝试获取一个正在被其他线程占用的锁,该线程会一直阻塞直到锁被其他线程释放。
这种方式有明显的缺点,当线程被阻塞时它不能做任何事情。
如果被阻塞的线程是较高优先级的任务,那么后果是灾难性的(这种危险被称为优先级倒置,priority inversion)。
使用锁还有其他一些风险,例如死锁(当以不一致的顺序获取多个锁时可能会发生死锁)。
即使没有这样的危险,锁也只是相对粗粒度的协调机制。
因此,对于管理简单的操作,如计数器或互斥体来说,锁是相当“重”的。
如果有一个更细粒度的机制能够可靠地管理对变量的并发更新,那将是极好的。
幸运的是,大多数现代处理器都有这种轻量级的机制。

硬件同步原语

如前所述,大多数现代处理都支持多处理器,这种支持除了基本的多个处理器共享外设和主存储器的能力,通常还包括对指令集的增强,以支持多处理的特殊要求。特别是,几乎每个现代处理器都具有用于更新共享变量的指令,该指令可以检测或阻止来自其他处理器的并发访问。CAS指令就是其中之一,由于它是在硬件中实现的,非常轻量级。

Compare and swap (CAS)

第一批支持并发的处理器提供了原子的test-and-set操作,这些操作通常在一个bit上进行(非0即1)。但是当前主流的处理器最常用的方法是实现一个叫compare-and-swap(CAS)的原语(32-bit的字段)。(在Intel处理器上,CAS是由cmpxchg指令系列实现的。PowerPC处理器有一对"load and reserve" 和 "store conditional"的指令达到同样的效果)
CAS操作涉及三个对象-内存位置(V)预期的旧值(A)新的值(B)
如果位置V的值与预期的值A相等,则处理器将原子地把V更新为新值B,否则不执行任何操作。无论哪种情况,CAS都会返回位置V之前的值。(有的CAS版本会简单地返回CAS是否执行成功,而不返回旧值。)
CAS表示:“我认为位置V应该有值A;如果有,则将B放入其中,否则,不要改变它,但我要知道现在是什么值。”
像CAS这样的指令允许程序执行 read-modify-write序列,而不必担心同时有另一个线程修改变量,因为如果其他线程修改了变量CAS会检测到该更新并失败,程序可以重试该操作。
清单3,通过synchronized模拟了CAS的内部逻辑。

Listing 3. the behavior (but not performance) of compare-and-swap
public class SimulatedCAS {
     private int value;
     public synchronized int getValue() { return value; }
    public synchronized int compareAndSwap(int expectedValue, int newValue) {
         int oldValue = value;
         if (value == expectedValue)
             value = newValue;
         return oldValue;
     }
}

使用CAS实现计数器

基于CAS的并发算法称为lock-free,因为线程不必等待锁。
无论CAS操作成功还是失败,它都可以在预期的时间内完成。如果CAS失败,则调用者可以重试CAS操作或采取其他合适措施。
清单4中使用CAS重写了计数器类:

Listing 4. Implementing a counter with compare-and-swap
public class CasCounter {
    private SimulatedCAS value;
    public int getValue() {
        return value.getValue();
    }
     public int increment() {
        int oldValue = value.getValue();//获取旧值
        while (value.compareAndSwap(oldValue, oldValue + 1)  !=  oldValue){ //不断重试
            oldValue = value.getValue();
        }
        return oldValue + 1;
    }
}

Lock-free和 wait-free 算法

wait-free算法顾名思义是不用等待的,保证每个线程都在执行。而lock-free算法是不加锁的,要求至少有一个线程能取得进展(make progress)。可见,wait-freelock-free的要求更苛刻。
在过去的15年中,人们对wait-free和lock-free算法(也称为非阻塞算法)进行了大量研究,并且发现了许多常见数据结构的非阻塞算法实现。
非阻塞算法在操作系统和JVM级别广泛用于诸如线程和进程调度之类的任务。
尽管实现起来较为复杂,但与基于锁的方案相比,它们具有许多优点,比如避免了优先级反转死锁之类的危险,竞争成本更低,并且协调发生在更细粒度级别,从而实现了更高程度的并行性。

Java中的原子变量类

在JDK5之前,要实现wait-free、lock-free的算法必须通过native方法。但是,在JDK5中增加了java.util.concurrent.atomic原子包后,情况发生了变化。该包提供了多种原子变量类,AtomicInteger、 AtomicLong、 AtomicBoolean等,每个类都暴露了一个compare-and-set原语,它使用了平台上可用的最快的原生结构,具体实现方案因平台而异(可能是compare-and-swap, load linked/store conditional, 或者最坏的情况使用 spin locks)。
可以将原子变量类视为volatile变量的泛化,它扩展了volatile变量的概念以支持原子的compare-and-set更新。
原子变量的读写与volatile变量的读写具有相同的内存语义。
尽管原子变量类看起来像清单1中的示例,但是他们的相似只是表面上的。在幕后,对变量的原子操作变成了平台提供的硬件原语

细粒度意味着更轻量

优化并发应用的一个常用技术是减少锁对象的粒度,可以让更多的锁获取从竞争的变成非竞争的。
把锁变成原子变量也达到了同样的效果,通过切换到更小粒度的协调机制,减少有竞争的操作,以提升系统吞吐量。

java.util.concurrent包中的原子变量

juc包中几乎所有的类都直接或间接的使用了原子变量,而不是synchronized。例如ConcurrentLinkedQueue类直接使用原子变量类实现了wait-free算法;ConcurrentHashMap类使用ReentrantLock上锁,而ReentrantLock使用原子变量类维护等待锁的线程队列。
如果没有JDK5的改进,这些类就无法实现,JDK5暴露了一个接口让类库可以使用硬件级的同步原语。而原子变量类以及juc中的其他类又把这些特性暴露给了用户类。

使用原子变量实现更高的吞吐量

清单5中分别使用同步和CAS实现了线程安全的伪随机数生成器(PRNG)。要注意的是CAS必须在循环中执行,因为它在成功之前可能会失败一次或多次,这几乎是CAS的使用范式。

Listing 5. Implementing a thread-safe PRNG with synchronization and atomic variables
//synchronization版
public class PseudoRandomUsingSynch implements PseudoRandom {
    private int seed;
    public PseudoRandomUsingSynch(int s) {  
        seed = s; 
    }
    public synchronized int nextInt(int n) {
        int s = seed;
        seed = Util.calculateNext(seed);
        return s % n;
    }
}
//lock-free版
public class PseudoRandomUsingAtomic implements PseudoRandom {
    //使用原子类
    private final AtomicInteger seed;
    public PseudoRandomUsingAtomic(int s) {
        seed = new AtomicInteger(s);
    }
    public int nextInt(int n) {
        //lock-free
        for (;;) {
            int s = seed.get();
            int nexts = Util.calculateNext(s);
            if (seed.compareAndSet(s, nexts))
                return s % n;
        }
    }
}

下面的两张图分别显示了在8路Ultrasparc3和单核的Pentium 4上的线程数与随机数生成器的吞吐量关系。
你会看到,原子变量(ATOMIC曲线)相对于ReentrantLock(LOCK曲线)有了进一步改进,而后者相比同步(SYNC曲线)取得显著的提升。(由于每个工作单元的工作量很少,因此下面的图形可能低估了原子变量与ReentrantLock相比在伸缩性方便的优势。)

大多数用户不大可能使用原子变量实现自己的非阻塞算法,而是更应该使用java.util.concurrent中提供的版本,例如ConcurrentLinkedQueue。
如果你想知道与之前的JDK中的类相比juc中的类的性能提升来自何处?那就是使用了原子变量类开放的更细粒度、硬件级并发原语。另外,开发人员可以直接将原子变量用作共享计数器、序列号生成器以及其他独立共享变量的高性能替代品,否则必须通过同步来保护它们。

Java线程池使用原子类的示例

这段代码是JDK 1.8中ThreadPoolExecutor的addWorker方法源码片段。
目的是向线程池中增加一个worker,由于worker最大数量是有限制的,所以对其执行添加操作必须是线程安全的。
变量ctl为AtomicInteger类型,用于存储worker数量

//while-loop
for (;;) {
    int wc = workerCountOf(c);
    if (wc >= CAPACITY ||
        wc >= (core ? corePoolSize : maximumPoolSize))
            return false;
     //内部操作:ctl.compareAndSet(expect, expect + 1);
    if (compareAndIncrementWorkerCount(c))
        break retry;
        c = ctl.get();  // Re-read ctl
        if (runStateOf(c) != rs)
            continue retry;
            // else CAS failed due to workerCount change; retry inner loop
}

总结

JDK 5.0在高性能并发的开发上迈出了一大步。它在内部暴露新的低层协调原语,并提供了一组公共的原子变量类。现在,你可以使用Java语言开发第一个wait-free,lock-free的算法了。不过,java.util.concurrent中的类都是基于这些原子变量工具构建的,与之前类似功能的类相比,在性能上有了质的飞跃,你可以直接使用他们。
尽管你可能永远不会直接使用原子变量,但是他们仍然值得我们为其欢呼。

参考:

  1. 原文: https://www.ibm.com/developerworks/library/j-jtp11234/index.html
  2. More flexible, scalable locking in JDK 5.0
    https://www.ibm.com/developerworks/java/library/j-jtp10264/index.html
  3. No-Blocking algorythm 维基百科: https://en.m.wikipedia.org/wiki/Non-blocking_algorithm
  4. wait-free和lock-free: https://www.zhihu.com/question/295904223
posted @ 2020-05-27 14:48  元思  阅读(555)  评论(0编辑  收藏  举报