目录
并发编程中我们需要注意的问题有很多,主要有安全性问题、活跃性问题和性能问题。接下来我们就一 一解析一下这三个问题。
一、安全性问题
1、什么是线程安全呢?
线程安全的本质就是正确性,而正确性的含义就是程序按照我们期望的执行,在《并发编程—可见性、原子性、有序性 BUG源头》一章中我们介绍了很多诡异性的问题,他们都是出乎我们预料的,没有按照我们的期望执行。我们知道导致 程序出现问题的源头是可见性、原子性和有序性,理论上线程安全的程序,就是要避免出现原子性问题、可见性问题和有序性问题。
那边是不是我们在开发中所有的代码都有撸一下是不是避免了这三个问题呢?是不需要的,其实只有一种情况需要关注那就是:存在共享数据并且该数据会发生变化,通俗地讲就是多个线程会同时读写统一数据。既然是由于共享数据变化导致的线程安全问题,那么是不是做到数据不共享或者数据静态不变是不是就可以了。也有不少结束时基于此理论的,比如 线程本地存储 (Thread Local Storage),不变模式等等。
但是在实际开发中,必须共享会发生变化的数据。并且这样的应用场景还是很多。
当多个线程同时访问同一数据,并且至少有一个线程会写这个数据时,如果我们不采取防护措施,那么就会导致并发问题,对此的专业名称为“数据竞争”。如下面的例子当多个线程同时执行add()方法时就会发生数据竞争。
public class Test {
private long count = 0;
synchronized long get(){
return count;
}
synchronized void set(long v){
count = v;
}
void add() {
int idx = 0;
while(idx++ < 10000) {
set(get()+1) // 1
}
}
}
你可能有些疑问,我在访问数据(get()方法) 和写数据 (set方法)上都加了锁了,为什么还会存在问题呢。我们分析一下这多代码,
假设 count=0,当两个线程同时执行 get() 方法时,get() 方法会返回相同的值 0,两个线程执行 get()+1 操作,结果都是 1,之后两个线程再将结果 1 写入了内存。你本来期望的是 2,而结果却是 1。
这段程序执行的结果依赖于线程执行的顺序,官方称为 竞态条件。在并发场景中,程序的执行依赖于某个状态变了,如下所示:
if (状态变量 满足 执行条件) {
执行操作
}
当某个线程发现状态变量满足执行条件后,开始执行操作;可是就在这个线程执行操作的时候,其他线程同时修改了状态变量,导致状态变量不满足执行条件了。这种情况就会引发线程安全问题。
那面对数据竞争和竞态条件问题,又该如何保证线程的安全性呢?其实这两类问题,都可以用互斥这个技术方案,而实现互斥的方案有很多,CPU 提供了相关的互斥指令,操作系统、编程语言也会提供相关的 API。从逻辑上来看,我们可以统一归为:锁。java中常用的就是 synchronized关键字、Lock接口以及原子操作。
二、活跃性问题
活跃性问题除了我们前面章节讲的“死锁”之外,还有两种情况,分别是“活锁” 和 “饥饿”。
在《并发编程—死锁了,怎么办?》我们知道 放生“死锁” 是由于线程之间互相等待,并且是一直等待。那么“活锁”又是什么情况呢?
1、活锁
所谓“活锁”就是:有时线程虽然没有发生阻塞,但是仍然会存在执行不下去的情况。举个例子就是, 比如 路人甲从左手边出门,路人乙从右手边进门,两人为了不相撞,互相谦让,路人甲让路走右边,路人乙让路走左边,结果两人又相撞,就像这种两人来回谦让,来回相撞,路人甲进不了门,路人乙也出不了门。
解决“活锁”的方案也很简单,就是谦让时,尝试等待一个随机的时间就可以了。
2、饥饿
所谓“饥饿”指的是线程因无法访问所需资源而无法执行下去的情况。“不患寡,而患不均”,如果线程优先级“不均”,在 CPU 繁忙的情况下,优先级低的线程得到执行的机会很小,就可能发生线程“饥饿”;持有锁的线程,如果执行的时间过长,也可能导致“饥饿”问题。
解决“饥饿”问题的方案很简单,有三种方案:
一、是保证资源充足,
二、是公平地分配资源,
三、就是避免持有锁的线程长时间执行。
这三个方案中,方案一和方案三的适用场景比较有限,因为很多场景下,资源的稀缺性是没办法解决的,持有锁的线程执行的时间也很难缩短。倒是方案二的适用场景相对来说更多一些。那如何公平地分配资源呢?在并发编程里,主要是使用公平锁。所谓公平锁,是一种先来后到的方案,线程的等待是有顺序的,排在等待队列前面的线程会优先获得资源。
三、性能问题
使用“锁”要非常小心,但是如果小心过度,也可能出“性能问题”。“锁”的过度使用可能导致串行化的范围过大,这样就不能够发挥多线程的优势了,而我们之所以使用多线程搞并发程序,为的就是提升性能。
所以使用锁的时候一定要关注对性能的影响。 那怎么才能避免锁带来的性能问题呢?这个问题很复杂,Java SDK 并发包里之所以有那么多东西,有很大一部分原因就是要提升在某个特定领域的性能。
不过从方案层面,我们可以这样来解决这个问题。
第一,既然使用锁会带来性能问题,那最好的方案自然就是使用无锁的算法和数据结构了。在这方面有很多相关的技术,例如线程本地存储 (Thread Local Storage, TLS)、写入时复制 (Copy-on-write)、乐观锁等;Java 并发包里面的原子类也是一种无锁的数据结构;Disruptor 则是一个无锁的内存队列,性能都非常好……
第二,减少锁持有的时间。互斥锁本质上是将并行的程序串行化,所以要增加并行度,一定要减少持有锁的时间。这个方案具体的实现技术也有很多,例如使用细粒度的锁,一个典型的例子就是 Java 并发包里的 ConcurrentHashMap,它使用了所谓分段锁的技术(这个技术后面我们会详细介绍);还可以使用读写锁,也就是读是无锁的,只有写的时候才会互斥。
性能方面的度量指标有很多,我觉得有三个指标非常重要,就是:吞吐量、延迟和并发量。
吞吐量:指的是单位时间内能处理的请求数量。吞吐量越高,说明性能越好。
延迟:指的是从发出请求到收到响应的时间。延迟越小,说明性能越好。
并发量:指的是能同时处理的请求数量,一般来说随着并发量的增加、延迟也会增加。所以延迟这个指标,一般都会是基于并发量来说的。例如并发量是 1000 的时候,延迟是 50 毫秒。总结
四、总结
并发编程是一个复杂的技术领域,微观上涉及到原子性问题、可见性问题和有序性问题,宏观则表现为安全性、活跃性以及性能问题。
我们在设计并发程序的时候,主要是从宏观出发,也就是要重点关注它的安全性、活跃性以及性能。安全性方面要注意数据竞争和竞态条件,活跃性方面需要注意死锁、活锁、饥饿等问题,性能方面我们虽然介绍了两个方案,但是遇到具体问题,你还是要具体分析,根据特定的场景选择合适的数据结构和算法。
要解决问题,首先要把问题分析清楚。同样,要写好并发程序,首先要了解并发程序相关的问题,经过这 7 章的内容,相信你一定对并发程序相关的问题有了深入的理解,同时对并发程序也一定心存敬畏,因为一不小心就出问题了。不过这恰恰也是一个很好的开始,因为你已经学会了分析并发问题,然后解决并发问题也就不远了。
参考: