主要内容
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()操作。清单1展示了使用同步锁实现此类。
请注意,所有方法,甚至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
组合操作出现在很多并发算法中。下面清单2中的代码实现了一个简单的互斥体(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)。
使用锁还有其他一些风险,例如死锁(当以不一致的顺序获取多个锁时可能会发生死锁)。
即使没有这样的危险,锁也只是相对粗粒度的协调机制。
因此,对于管理简单的操作(例如计数器或互斥体)来说,锁是相当“重”的。
如果有一个更细粒度的机制能够可靠地管理对变量的并发更新,那将是极好的。
幸运的是,大多数现代处理器都有这种轻量级的机制。
硬件同步原语
如前所述,大多数现代处理都支持多处理器,这种支持除了基本的多个处理器共享外设和主存储器的能力,它通常还包括对指令集的增强,以支持多处理的特殊要求。特别是,几乎每个现代处理器都具有用于更新共享变量的指令,该指令可以检测或阻止来自其他处理器的并发访问。
Compare and swap (CAS)
第一批支持并发的处理器提供了原子的test-and-set
操作,这些操作通常在一个bit上进行(非0即1)。但是当前主流的处理器(包括Intel和Sparc处理器)最常用的方法是实现一个被称为compare-and-swap(CAS)
的原语(32-bit的字段)。(在Intel处理器上,CAS是由cmpxchg指令系列实现的。PowerPC处理器有一对"load and reserve" 和 "store conditional"的指令达到同样的效果)
CAS包括三个操作对象-内存位置(V)
,预期的旧值(A)
和新的值(B)
。
如果该位置的值V与预期的旧值A匹配,则处理器将原子地将该位置更新为新值B,否则它将不执行任何操作。
无论哪种情况,它都会返回CAS指令之前该位置的值V。 (CAS的某些版本会简单地返回CAS是否成功,而不获取当前值。)
CAS表示:“我认为位置V应该有值A;如果有,则将B放入其中,否则,不要改变它,但要告诉我现在有什么值。”
CAS通常的使用方法是,从地址V读取值A,然后对A执行多次计算得到新值B,最后使用CAS指令将位置V的值从A变为B。
如果该位置V同时没有被其他处理器更新,那么CAS就会成功。
像CAS这样的指令允许程序执行 read-modify-write
序列,而不必担心同时有另一个线程修改变量,因为如果另一个线程确实修改了变量,则CAS会检测到该变量(并失败),并且程序可以重试该操作。
清单3,通过synchronized模拟了CAS的内部逻辑。(不包括性能模拟,也没办法模拟,因为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
算法保证每个线程都在执行(make progress)。相反的,lock-free
算法要求至少有一个线程能取得进展(make progress)。
可见,wait-free
比lock-free
的要求更苛刻。
在过去的15年中,人们对wait-free和lock-free算法(也称为非阻塞算法)进行了大量研究,并且发现了许多常见数据结构的非阻塞算法实现。
非阻塞算法在操作系统和JVM级别广泛用于诸如线程和进程调度之类的任务。
尽管实现起来较为复杂,但与基于锁的替代方法相比,它们具有许多优点,
避免了诸如优先级反转
和死锁
之类的危险,竞争成本更低,并且协调发生在更细粒度级别,从而实现了更高程度的并行性。
原子变量类
在JDK5之前,要实现wait-free、lock-free的算法必须通过native方法。但是,在JDK5中增加了java.util.concurrent.atomic原子包后,情况发生了变化。
atomic包提供了多种原子变量类(AtomicInteger; AtomicLong; AtomicReference; AtomicBoolean等)。
原子变量类都暴露了一个compare-and-set
原语(类似compare-and-swap),它使用了平台上可用的最快的原生结构,具体实现方案因平台而异(可能是compare-and-swap, load linked/store conditional, 或者最坏的情况使用 spin locks)。
可以将原子变量类视为volatile变量的泛化,它扩展了volatile变量的概念以支持原子的compare-and-set更新。
原子变量的读写与volatile变量的读写具有相同的内存语义。
尽管原子变量类或许看起来像清单1中的示例,但是他们的相似只是表面上的。
在幕后,对原子变量的操作变成了平台提供的硬件原语,例如compare-and-swap。
细粒度意味着更轻量
优化并发应用的一个常用技术是减少锁对象的粒度,可以让更多的锁获取从竞争的变成非竞争的。
把锁变成原子变量也达到了同样的效果,通过切换到更小粒度的协调机制,减少有竞争的操作,以提升系统吞吐量。
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
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;
}
}
public class PseudoRandomUsingAtomic implements PseudoRandom {
private final AtomicInteger seed;
public PseudoRandomUsingAtomic(int s) {
seed = new AtomicInteger(s);
}
public int nextInt(int n) {
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中的类的性能提升来自何处?那就是使用了原子变量类开放的更细粒度、硬件级并发原语。
另外,开发人员可以直接将原子变量用作共享计数器、序列号生成器以及其他独立共享变量的高性能替代品,否则必须通过同步来保护它们。
总结
JDK 5.0在高性能并发的开发上迈出了一大步。它在内部暴露新的低层协调原语,并提供了一组公共的原子变量类。现在,你可以使用Java语言开发第一个wait-free,lock-free的算法了。
不过,java.util.concurrent中的类都是基于这些原子变量工具构建的,与之前类似功能的类相比,在性能上有了质的飞跃,你可以直接使用他们。
尽管你可能永远不会直接使用原子变量,但是他们仍然值得我们为其欢呼。
译者注
这篇文章是Brian Goetz发表于2004年,即JDK5刚刚发布之后,作为Java布道者第一时间对JDK5的新特性做了很透彻的说明。
Brian Goetz是Java语言的架构师,是Lambda项目的主导者,也是《Java Concurrency in Practice》作者。
参考:
- 原文: https://www.ibm.com/developerworks/library/j-jtp11234/index.html
- More flexible, scalable locking in JDK 5.0
https://www.ibm.com/developerworks/java/library/j-jtp10264/index.html- No-Blocking algorythm 维基百科: https://en.m.wikipedia.org/wiki/Non-blocking_algorithm
- wait-free和lock-free: https://www.zhihu.com/question/295904223