You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

154 lines
13 KiB
Markdown

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

# 07 | 安全性、活跃性以及性能问题
通过前面六篇文章,我们开启了一个简单的并发旅程,相信现在你对并发编程需要注意的问题已经有了更深入的理解,这是一个很大的进步,正所谓只有发现问题,才能解决问题。但是前面六篇文章的知识点可能还是有点分散,所以是时候将其总结一下了。
并发编程中我们需要注意的问题有很多,很庆幸前人已经帮我们总结过了,主要有三个方面,分别是:**安全性问题、活跃性问题和性能问题**。下面我就来一一介绍这些问题。
## 安全性问题
相信你一定听说过类似这样的描述:这个方法不是线程安全的,这个类不是线程安全的,等等。
那什么是线程安全呢?其实本质上就是正确性,而正确性的含义就是**程序按照我们期望的执行**,不要让我们感到意外。在[第一篇《可见性、原子性和有序性问题并发编程Bug的源头》](https://time.geekbang.org/column/article/83682)中我们已经见识过很多诡异的Bug都是出乎我们预料的它们都没有按照我们**期望**的执行。
那如何才能写出线程安全的程序呢?[第一篇文章](https://time.geekbang.org/column/article/83682)中已经介绍了并发Bug的三个主要源头原子性问题、可见性问题和有序性问题。也就是说理论上线程安全的程序就要避免出现原子性问题、可见性问题和有序性问题。
那是不是所有的代码都需要认真分析一遍是否存在这三个问题呢?当然不是,其实只有一种情况需要:**存在共享数据并且该数据会发生变化,通俗地讲就是有多个线程会同时读写同一数据**。那如果能够做到不共享数据或者数据状态不发生变化不就能够保证线程的安全性了嘛。有不少技术方案都是基于这个理论的例如线程本地存储Thread Local StorageTLS、不变模式等等后面我会详细介绍相关的技术方案是如何在Java语言中实现的。
但是,现实生活中,**必须共享会发生变化的数据**,这样的应用场景还是很多的。
当多个线程同时访问同一数据并且至少有一个线程会写这个数据的时候如果我们不采取防护措施那么就会导致并发Bug对此还有一个专业的术语叫做**数据竞争**Data Race。比如前面[第一篇文章](https://time.geekbang.org/column/article/83682)里有个add10K()的方法,当多个线程调用时候就会发生**数据竞争**,如下所示。
```
public class Test {
private long count = 0;
void add10K() {
int idx = 0;
while(idx++ < 10000) {
count += 1;
}
}
}
```
那是不是在访问数据的地方,我们加个锁保护一下就能解决所有的并发问题了呢?显然没有这么简单。例如,对于上面示例,我们稍作修改,增加两个被 synchronized 修饰的get()和set()方法, add10K()方法里面通过get()和set()方法来访问value变量修改后的代码如下所示。对于修改后的代码所有访问共享变量value的地方我们都增加了互斥锁此时是不存在数据竞争的。但很显然修改后的add10K()方法并不是线程安全的。
```
public class Test {
private long count = 0;
synchronized long get(){
return count
}
synchronized void set(long v){
count = v;
}
void add10K() {
int idx = 0;
while(idx++ < 10000) {
set(get()+1)
}
}
}
```
假设count=0当两个线程同时执行get()方法时get()方法会返回相同的值0两个线程执行get()+1操作结果都是1之后两个线程再将结果1写入了内存。你本来期望的是2而结果却是1。
这种问题,有个官方的称呼,叫**竞态条件**Race Condition。所谓**竞态条件,指的是程序的执行结果依赖线程执行的顺序**。例如上面的例子如果两个线程完全同时执行那么结果是1如果两个线程是前后执行那么结果就是2。在并发环境里线程的执行顺序是不确定的如果程序存在竞态条件问题那就意味着程序执行的结果是不确定的而执行结果不确定这可是个大Bug。
下面再结合一个例子来说明下**竞态条件**就是前面文章中提到的转账操作。转账操作里面有个判断条件——转出金额不能大于账户余额但在并发环境里面如果不加控制当多个线程同时对一个账号执行转出操作时就有可能出现超额转出问题。假设账户A有余额200线程1和线程2都要从账户A转出150在下面的代码里有可能线程1和线程2同时执行到第6行这样线程1和线程2都会发现转出金额150小于账户余额200于是就会发生超额转出的情况。
```
class Account {
private int balance;
// 转账
void transfer(
Account target, int amt){
if (this.balance > amt) {
this.balance -= amt;
target.balance += amt;
}
}
}
```
所以你也可以按照下面这样来理解**竞态条件**。在并发场景中,程序的执行依赖于某个状态变量,也就是类似于下面这样:
```
if (状态变量 满足 执行条件) {
执行操作
}
```
当某个线程发现状态变量满足执行条件后开始执行操作可是就在这个线程执行操作的时候其他线程同时修改了状态变量导致状态变量不满足执行条件了。当然很多场景下这个条件不是显式的例如前面addOne的例子中set(get()+1)这个复合操作其实就隐式依赖get()的结果。
那面对数据竞争和竞态条件问题,又该如何保证线程的安全性呢?其实这两类问题,都可以用**互斥**这个技术方案,而实现**互斥**的方案有很多CPU提供了相关的互斥指令操作系统、编程语言也会提供相关的API。从逻辑上来看我们可以统一归为**锁**。前面几章我们也粗略地介绍了如何使用锁,相信你已经胸中有丘壑了,这里就不再赘述了,你可以结合前面的文章温故知新。
## 活跃性问题
所谓活跃性问题,指的是某个操作无法执行下去。我们常见的“死锁”就是一种典型的活跃性问题,当然**除了死锁外,还有两种情况,分别是“活锁”和“饥饿”**。
通过前面的学习你已经知道,发生“死锁”后线程会互相等待,而且会一直等待下去,在技术上的表现形式是线程永久地“阻塞”了。
但**有时线程虽然没有发生阻塞,但仍然会存在执行不下去的情况,这就是所谓的“活锁”**。可以类比现实世界里的例子,路人甲从左手边出门,路人乙从右手边进门,两人为了不相撞,互相谦让,路人甲让路走右手边,路人乙也让路走左手边,结果是两人又相撞了。这种情况,基本上谦让几次就解决了,因为人会交流啊。可是如果这种情况发生在编程世界了,就有可能会一直没完没了地“谦让”下去,成为没有发生阻塞但依然执行不下去的“活锁”。
解决“**活锁**”的方案很简单谦让时尝试等待一个随机的时间就可以了。例如上面的那个例子路人甲走左手边发现前面有人并不是立刻换到右手边而是等待一个随机的时间后再换到右手边同样路人乙也不是立刻切换路线也是等待一个随机的时间再切换。由于路人甲和路人乙等待的时间是随机的所以同时相撞后再次相撞的概率就很低了。“等待一个随机时间”的方案虽然很简单却非常有效Raft这样知名的分布式一致性算法中也用到了它。
那“**饥饿**”该怎么去理解呢?**所谓“饥饿”指的是线程因无法访问所需资源而无法执行下去的情况**。“不患寡而患不均”如果线程优先级“不均”在CPU繁忙的情况下优先级低的线程得到执行的机会很小就可能发生线程“饥饿”持有锁的线程如果执行的时间过长也可能导致“饥饿”问题。
解决“**饥饿**”问题的方案很简单,有三种方案:一是保证资源充足,二是公平地分配资源,三就是避免持有锁的线程长时间执行。这三个方案中,方案一和方案三的适用场景比较有限,因为很多场景下,资源的稀缺性是没办法解决的,持有锁的线程执行的时间也很难缩短。倒是方案二的适用场景相对来说更多一些。
那如何公平地分配资源呢?在并发编程里,主要是使用公平锁。所谓公平锁,是一种先来后到的方案,线程的等待是有顺序的,排在等待队列前面的线程会优先获得资源。
## 性能问题
使用“锁”要非常小心,但是如果小心过度,也可能出“性能问题”。“锁”的过度使用可能导致串行化的范围过大,这样就不能够发挥多线程的优势了,而我们之所以使用多线程搞并发程序,为的就是提升性能。
所以我们要尽量减少串行那串行对性能的影响是怎么样的呢假设串行百分比是5%,我们用多核多线程相比单核单线程能提速多少呢?
有个阿姆达尔Amdahl定律代表了处理器并行运算之后效率提升的能力它正好可以解决这个问题具体公式如下
$S=\\frac{1}{(1-p)+\\frac{p}{n}}$
公式里的n可以理解为CPU的核数p可以理解为并行百分比1-p就是串行百分比了也就是我们假设的5%。我们再假设CPU的核数也就是n无穷大那加速比S的极限就是20。也就是说如果我们的串行率是5%那么我们无论采用什么技术最高也就只能提高20倍的性能。
所以使用锁的时候一定要关注对性能的影响。 那怎么才能避免锁带来的性能问题呢?这个问题很复杂,**Java SDK并发包里之所以有那么多东西有很大一部分原因就是要提升在某个特定领域的性能**。
不过从方案层面,我们可以这样来解决这个问题。
第一,既然使用锁会带来性能问题,那最好的方案自然就是使用无锁的算法和数据结构了。在这方面有很多相关的技术,例如线程本地存储(Thread Local Storage, TLS)、写入时复制(Copy-on-write)、乐观锁等Java并发包里面的原子类也是一种无锁的数据结构Disruptor则是一个无锁的内存队列性能都非常好……
第二减少锁持有的时间。互斥锁本质上是将并行的程序串行化所以要增加并行度一定要减少持有锁的时间。这个方案具体的实现技术也有很多例如使用细粒度的锁一个典型的例子就是Java并发包里的ConcurrentHashMap它使用了所谓分段锁的技术这个技术后面我们会详细介绍还可以使用读写锁也就是读是无锁的只有写的时候才会互斥。
性能方面的度量指标有很多,我觉得有三个指标非常重要,就是:吞吐量、延迟和并发量。
1. 吞吐量:指的是单位时间内能处理的请求数量。吞吐量越高,说明性能越好。
2. 延迟:指的是从发出请求到收到响应的时间。延迟越小,说明性能越好。
3. 并发量指的是能同时处理的请求数量一般来说随着并发量的增加、延迟也会增加。所以延迟这个指标一般都会是基于并发量来说的。例如并发量是1000的时候延迟是50毫秒。
## 总结
并发编程是一个复杂的技术领域,微观上涉及到原子性问题、可见性问题和有序性问题,宏观则表现为安全性、活跃性以及性能问题。
我们在设计并发程序的时候,主要是从宏观出发,也就是要重点关注它的安全性、活跃性以及性能。安全性方面要注意数据竞争和竞态条件,活跃性方面需要注意死锁、活锁、饥饿等问题,性能方面我们虽然介绍了两个方案,但是遇到具体问题,你还是要具体分析,根据特定的场景选择合适的数据结构和算法。
要解决问题首先要把问题分析清楚。同样要写好并发程序首先要了解并发程序相关的问题经过这7章的内容相信你一定对并发程序相关的问题有了深入的理解同时对并发程序也一定心存敬畏因为一不小心就出问题了。不过这恰恰也是一个很好的开始因为你已经学会了分析并发问题然后解决并发问题也就不远了。
## 课后思考
Java语言提供的Vector是一个线程安全的容器有同学写了下面的代码你看看是否存在并发问题呢
```
void addIfNotExist(Vector v,
Object o){
if(!v.contains(o)) {
v.add(o);
}
}
```
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。