0%

安全性、活跃性以及性能问题

并发编程中需要注意的问题有很多,主要有三个方面:安全性问题、活跃性问题、性能问题。

安全性问题

线程安全,本质上就是正确性,正确性的含义就是程序按照期望的执行,不要让人感到意外。

并发Bug的三个主要源头:原子性问题、可见性问题、有序性问题。理论上线程安全的程序,就要避免这三个问题。存在共享数据并且该数据会发生变化,通俗的讲就是有多个线程会同时读写同一数据。只有这种情况的代码,才需要分析是否存在这三个问题。

如果能够做到不共享数据或者数据状态不发生变化,就能够保证线程的安全性了。如线程本地存储(Thread Local Storage,TLS)、不变模式等都是基于这个理论的。

当多个线程同时访问同一数据,并且至少有一个线程会写这个数据的时候,如果不采取防护措施,那么就会导致并发Bug。术语-数据竞争(Data Race)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
public class test {
private long count = 0;
void add10K() {
int idx = 0;
while(idx++ < 10000) {
count += 1;
}
}
}

//所有访问共享变量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。

单单在访问数据的地方,加个锁保护一下并不能解决所有的并发问题。在并发环境里,线程的执行顺序是不确定的,如果程序存在竞态条件问题(所谓竞态条件,指的是程序的执行结果依赖线程执行的顺序),那就意味着程序执行的结果是不确定的,这是个大Bug。

转账操作里面有个判断条件-转出金额不能大于账户余额,但在并发环境里面,如果不加控制,当多个线程同时对一个账号执行转出操作时,就有可能出现超额转出问题。

1
2
3
4
5
6
7
8
9
10
11
12
class Account {
private int balance;
//转账
void tarnsfer(Account target, int amt) {
if(this.balance > amt) {
this.balance -= amt;
target.balance += amt;
}
}
//假设账户A有余额200,线程1和线程2都要从账户A转出150。
//有可能线程1和线程2同时执行到第6行,这样线程1和线程2都会发现转出金额150小于账户余额200,于是就会发生超额转出的情况。
}

在并发场景中,程序的执行依赖于某个状态变量-竞态条件

1
2
3
if(状态变量 满足 执行条件) {
执行操作
}

当某个线程发现状态变量满足执行条件后,开始执行操作;可是就在这个线程执行操作的时候,其他线程同时修改了装填变量,导致状态变量不满足执行条件了。很多场景下,这个条件不是显示的,如addOne的例子中,set(get() + 1)这个符合操作(不是原子的),就是隐式依赖get()的结果(数据竞争)。

面对数据竞争和竞态条件问题,都可以用互斥这个技术方案解决。实现互斥的方案有很多,CPU提供了相关的互斥指令,操作系统、编程语言也会提供相关的API。从逻辑上来看,可以统一归为:

活跃性问题

活跃性问题,指的是某个操作无法执行下去。常见的“死锁”就是一种典型的活跃性问题,除了死锁还有两种情况,分别是“活锁”和“饥饿”。

发生“死锁”后线程会互相等待,而且会一直等待下去,在技术上的变现形式是线程永久的“阻塞”了。

有时线程虽然没有发生阻塞,但仍然会存在执行不下去的情况,这就是所谓的“活锁”

路人甲从左手边出门,路人乙从右手边出门,两人为了不相撞,互相谦让,路人甲让路走右手边,路人乙也让路走左手边,结果是两人又相撞了。在编程世界中,可能会一直没完没了的“谦让”下去,成为没有发生阻塞但依然执行不下去的“活锁”。

解决活锁的方案,谦让时,尝试等待一个随机的时间就可以了。(分布式一致性算法Raft用到该方案)

路人甲走左手边发现前面有人,并不是立刻换到右手边,而是等待一个随机的时间后,再换到右手边;同样路人乙也不是立刻切换路线,也是等待一个随机的时间再切换。由于路人甲和路人乙等待的时间是随机的,所以同时相撞后再次相撞的概率就很低了。

所谓饥饿,指的是线程因无法访问所需资源而无法执行下去的情况
“不患寡而患不均”,如果线程优先级“不均”,在CPU繁忙的情况下,优先级低的线程得到执行的机会很小,就可能发生线程“饥饿”;持有锁的线程,如果执行的时间过长,也可能导致“饥饿”问题。

解决饥饿问题有三种方案:一是保证资源充足,二是公平的分配资源,三是避免持有锁的线程长时间执行。
这三个方案中,方案一和方案三的适用场景比较有限,在很多的情况下,资源的稀缺性是没办法解决的,持有锁的线程执行的时间也很难缩短。方案二的适用场景相对来说更多一些。
公平的分配资源,在并发编程里,主要是使用公平锁。所谓公平锁,是一种先来后到的方案,线程的等待时有顺序的,排在等待队列前面的线程会优先获得资源。

性能问题

使用锁要非常小心,但是如果小心过度,也可能出“性能问题”。“锁”的过度使用可能导致串行化的范围过大,这样就不能够发挥多线程的优势了,而使用多线程搞并发程序,为的就是提升性能。所以要尽量减少串行。

阿姆达尔(Amdahl)定律:S=1/((1−p)+n/p)​​,代表了处理器并行运算之后效率提升的能力,解决多核多线程相比单核单线程的提速。

公式里的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毫秒。

总结

并发编程微观上涉及到原子性问题、可见性问题、有序性问题,宏观上则表现为安全性、活跃性、性能问题。

在设计并发程序的时候,主要是从宏观触发,也就是要重点关注它的安全性、活跃性、性能。安全性方面要注意数据竞争和竞态条件,活跃性方面要注意死锁、活锁、饥饿等,性能方面,遇到具体问题具体分析,根据特定场景选择合适的数据结构和算法。