Preface
Last updated
Last updated
管程(Monitor)作为一种解决并发问题的模型,是继信号量模型之后的一项重大创新,它与信号量在逻辑上是等价的(可以用管程实现信号量,也可以用信号量实现管程),但是相比之下管程更易用。
管程,指的是管理共享变量以及对共享变量的操作过程,让他们支持并发。
管程有三种模型:Hasen 模型、Hoare 模型、MESA 模型。
应用最广泛的事 MESA 模型。
并发编程的两大核心问题:互斥、同步。
将共享变量及其对共享变量的操作统一封装起来。可以看出,管程和面向对象的思路类似,这可能也是 Java 选择管程模型的原因之一吧。
入口等待队列:当多个线程同时试图进入管程内部时,只允许一个线程进入,其他线程则在入口等待队列中等待。可参考 Java synchronized 实现中 ObjectMonitor 对象的 _EntryList 字段。
条件变量:
条件变量等待队列:每个条件变量都对应有一个等待队列。可参考 Java synchronized 实现中 ObjectMonitor 对象的 _WaitSet 字段。
进入条件变量等待队列:在 Java 里面采用 wait() 或 Condition.await() 实现。
通知条件变量:在 Java 里面采用 notify()/notifyAll() 或 Condition.signal() 实现。
管程的三种模型的核心区别在于如何通知相关线程:
Hasen 模型: 要求通知放在最后,线程 T2通知 T1后,T2就运行结束了,然后 T1执行。
Hoare 模型:T2 通知T1后,T2阻塞,T1执行;等 T1执行完,唤醒 T2。所以 T2多了一次阻塞唤醒操作。
MESA 模型:T2通知T1后,T2继续执行,T1从条件等待队列进入入口等待队列。
优点:通知不用放在最后,T2也没有多余的阻塞唤醒操作。
缺点:当 T1再次执行的时候,可能条件已经变化不满足了。所以 MESA 模型需要以循环的方式检验条件变量,有一个编程范式,见下面代码。
notify()
何时用一般来说,除非经过深思熟虑,否则尽量使用 notifyAll()
。
当满足以下三个条件时,可以使用 notify():
所有等待线程拥有相同的等待条件;
所有等待线程被唤醒后,执行相同的操作;
只需要唤醒一个线程
管程有三种模型,Java 参考了 MESA 模型,并实现了它。
synchronized
、wait()
、notify()
不过是操作系统领域里管程模型的一种实现而已。Java SDK 并发包里的 Lock
和 Condition
是管程的另一种实现。
内置的 synchronized
是对 MESA 模型的精简,MESA 模型可以有多个条件变量,synchronized 只有一个。但是使用简单,会自动加锁、解锁。
JDK 并发包的 Lock
和 Condition
是另一套实现,支持多个条件变量,但是需要手动加锁和解锁。
信号量(Semaphore) 在1965年提出,之后一直都是并发编程领域的王者,直到管程模型被提出。
信号量模型:一个计数器、一个等待队列、三个方法。
init()
:设置计数器初始值。
down()
:计数器减 1;如果此时计数器的值小于 0,则当前线程进入等待队列阻塞,否则当前线程可以继续执行。
up()
:计数器的值加 1;如果此时计数器的值小于或者等于 0,则唤醒等待队列中的一个线程,并将其从等待队列中移除。
这三个方法都是原子性的,由实现方保证。
Java 的信号量由java.util.concurrent.Semaphore
实现,up()
对应acquire()
,down()
对应release()
。
并发编程可以总结为三个核心问题:分工、同步、互斥。
为了性能,如 IO 等待的时候不能让 CPU 闲着,所以我们把任务拆分交替执行,有了分时操作系统,出现了并发,后来 CPU 多核了又有了并行计算,也就是分工。
线程之间需要通信,于是操作系统提供了一些让进程,线程之间通信的方式,也就是同步。
并发和通信带来了多线程并发操作共享资源的问题,又要将对共享资源的访问串行化,所以设计了了锁,信号量等,也就是互斥。
分工指的是如何高效地拆解任务并分配给线程。
Java SDK 并发包里的 Executor、Fork/Join、Future 本质上都是一种分工方法。
并发编程领域的设计模式:生产者 - 消费者、Thread-Per-Message、Worker Thread 模式等都是用来指导你如何分工的。
同步指的是线程之间如何协作。
Java SDK 并发包里的 Executor、Fork/Join、Future 本质上都是分工方法,但同时也能解决线程协作的问题。Java SDK 里提供的 CountDownLatch、CyclicBarrier、Phaser、Exchanger 也都是用来解决线程协作问题的。
协作问题的本质:当某个条件不满足时,线程需要等待,当某个条件满足时,线程需要被唤醒执行。
解决协作问题的核心技术是管程,上面提到的所有线程协作技术底层都是利用管程解决的。
互斥是保证同一时刻只允许一个线程访问共享资源。
分工、同步主要强调的是性能,但并发程序里还有一部分是关于正确性的,用专业术语叫“线程安全”。
不正确的主要源头是可见性问题、有序性问题和原子性问题。Java 引入内存模型可以避免可见性问题、有序性问题,但是还不足以完全解决线程安全问题。解决线程安全问题的核心方案还是互斥。
实现互斥的核心技术就是锁,Java 语言里 synchronized
、SDK 里的各种 Lock
都能解决互斥问题。
CountDownLatch 就是一种典型的同步方式,而可重入锁则是一种互斥手段。
CPU、内存、I/O 设备这三者的速度差别非常大,为了合理利用 CPU 的性能,平衡三者的速度,一般有如下措施:
硬件:CPU 增加缓存,均衡 CPU 与内存的速度。会带来可见性问题。
操作系统:增加进程、线程,分时复用 CPU,均衡 CPU 与 IO 设备的速度。会带来原子性问题。
编译器:优化指令执行顺序,使缓存能更加合理利用。会带来有序性问题。
但是这三个优化又带来了问题。
CPU 缓存导致可见性问题。一个线程对共享变量的修改,另外一个线程能够立刻看到,我们称为可见性。
单核 CPU 倒是没什么问题,但是多核 CPU 每核有自己的缓存,会导致可见性问题。
分时操作系统的线程切换带来原子性问题。一个或者多个操作在 CPU 执行的过程中不被中断的特性称为原子性。CPU 能保证的原子操作是 CPU 指令级别的,而不是高级语言的操作符。
32位机器上对 long 进行操作不具有原子性。所以对 long 和 double 类型的变量,如有并发操作,最好加 volatile 或加锁。
编译优化带来有序性问题。
如下代码是双重检查机制创建单例对象,但是有问题。在 new 一个对象时,重排序后可能的执行方式为:
分配一块内存 M;
将 M 的地址赋值给 instance 变量;
最后在内存 M 上初始化 Singleton 对象。
若线程 A 拿到锁,执行了 new 方法,然后切换到线程 B,那么有可能线程 B 判断 instance 不为 null(锁外面的检查),但是 instance 的内容还没初始化。可以用 volatile
解决,禁止指令重排序。
并发编程中我们需要注意的问题有很多,主要是:安全性问题、活跃性问题、性能问题。
线程安全本质上就是正确性,而正确性的含义就是程序按照我们期望的执行。上文已经提到并发 Bug 的源头是原子性、可见性、有序性。但并不是所有的代码都需要分析上面三个问题,只有一种情况需要:存在共享数据并且该数据会发生变化,通俗地讲就是有多个线程会同时读写同一数据。
所以若能做到不共享数据或者数据状态不发生变化,也能够保证线程安全。这类技术方案有线程本地存储(Thread Local Storage,TLS)、不变模式等等。
当多个线程同时访问同一数据,并且至少有一个线程会写这个数据的时候,叫做数据竞争(Data Race)。
竞态条件(Race Condition):程序的执行结果依赖线程执行的顺序。也可以这样理解竞态条件,在并发场景中,程序的执行依赖于某个状态变量,当某个线程发现状态变量满足执行条件后,开始执行操作;可是就在这个线程执行操作的时候,其他线程同时修改了状态变量,导致状态变量不满足执行条件了。
面对数据竞争和竞态条件问题,都可以用互斥这个技术方案,逻辑上可以统一为:锁。
活跃性问题:某个操作无法执行下去。有三种情况:
死锁:一组互相竞争资源的线程因互相等待,导致“永久”阻塞的现象。
活锁:线程虽然没有发生阻塞,但仍然会存在执行不下去。可以用一个随机等待时间解决。Raft 分布式一致性算法中也用到了。
饥饿:线程因无法访问所需资源而无法执行下去。
若线程优先级不均匀,低优先级线程执行机会很小,则可能发生饥饿;
持有锁的线程,执行时间太长,也可能导致饥饿;
解决方案:一保证资源充足,二公平分配资源,三避免持有锁的线程执行过长。一、三比较难满足,第二点可以用公平锁(先来后到的方案,线程的等待是有顺序的)来解决。
锁的过度使用可能导致串行化的范围过大,这样就不能够发挥多线程的优势了。
无锁的算法和数据结构:线程本地存储 (Thread Local Storage, TLS)、写入时复制 (Copy-on-write)、乐观锁等;原子类是无锁的数据结构;Disruptor 是一个无锁的内存队列。
减少锁持有的时间:使用细粒度的锁(如 ConcurrentHashMap 1.8之前),读写锁等。
性能指标:
吞吐量:单位时间内能处理的请求数量。
延迟:从发出请求到收到响应的时间。
并发量:能同时处理的请求数量。一般来说随着并发量的增加、延迟也会增加。所以延迟这个指标,一般都会是基于并发量来说的。例如并发量是 1000 的时候,延迟是 50 毫秒。