一、安全性问题
并发 Bug 的三个主要源头:原子性问题、可见性问题和有序性问题。
理论上线程安全的程序,就要避免出现原子性问题、可见性问题和有序性问题。
存在共享数据并且该数据会发生变化,有多个线程会同时读写同一数据。所以做到不共享数据或者数据状态不发生变化,就能够保证线程的安全性了。
1.竞态条件
竞态条件,指的是程序的执行结果依赖线程执行的顺序。
在并发环境里,线程的执行顺序是不确定的,如果程序存在竞态条件问题,那就意味着程序执行的结果是不确定的,而执行结果不确定这可是个大 Bug。
在并发场景中,程序的执行依赖于某个状态变量:
if (状态变量 满足 执行条件) {
执行操作
}
当某个线程发现状态变量满足执行条件后,开始执行操作;可是就在这个线程执行操作的时候,其他线程同时修改了状态变量,导致状态变量不满足执行条件了。很多场景下,这个条件不是显式的。
面对数据竞争和竞态条件问题,可以用互斥方案,实现互斥的方案有很多,CPU 提供了相关的互斥指令,操作系统、编程语言也会提供相关的 API。从逻辑上来看,可以统一归为:锁。
二、活跃性问题
所谓活跃性问题,指的是某个操作无法执行下去。
1.死锁
发生“死锁”后线程会互相等待,而且会一直等待下去,在技术上的表现形式是线程永久地“阻塞”了。
2.活锁
线程没有发生阻塞,但仍然会存在执行不下去的情况,这就是所谓的“活锁”。
路人甲从左手边出门,路人乙从右手边进门,两人为了不相撞,互相谦让,路人甲让路走右手边,路人乙也让路走左手边,结果是两人又相撞了。这种情况,基本上谦让几次就解决了,因为人会交流啊。可是如果这种情况发生在编程世界了,就有可能会一直没完没了地“谦让”下去,成为没有发生阻塞但依然执行不下去的“活锁”。
解决:等待一个随机时间
谦让时,尝试等待一个随机的时间就可以了。例如上面的那个例子,路人甲走左手边发现前面有人,并不是立刻换到右手边,而是等待一个随机的时间后,再换到右手边;同样,路人乙也不是立刻切换路线,也是等待一个随机的时间再切换。由于路人甲和路人乙等待的时间是随机的,所以同时相撞后再次相撞的概率就很低了。“等待一个随机时间”的方案虽然很简单,却非常有效,Raft 的分布式一致性算法中也用到了它。
3.饥饿
“饥饿”指的是线程因无法访问所需资源而无法执行下去的情况。
“不患寡,而患不均”,如果线程优先级“不均”,在 CPU 繁忙的情况下,优先级低的线程得到执行的机会很小,就可能发生线程“饥饿”;持有锁的线程,如果执行的时间过长,也可能导致“饥饿”问题。
解决的三种方案:
- 保证资源充足
- 公平地分配资源:使用公平锁
- 避免持有锁的线程长时间执行
三、性能问题
“锁”使用过度,可能出“性能问题”,“锁”的过度使用可能导致串行化的范围过大,这样就不能够发挥多线程的优势了,而之所以使用多线程搞并发程序,为的就是提升性能。
1.使用无锁的算法和数据结构
- 线程本地存储 (Thread Local Storage, TLS)
- 写入时复制 (Copy-on-write)
- 乐观锁
- Java 并发包里面的原子类
- Disruptor ,一个无锁的内存队列
2.减少锁持有的时间
互斥锁本质上是将并行的程序串行化,所以要增加并行度,一定要减少持有锁的时间。
- 使用细粒度的锁(ConcurrentHashMap使用了分段锁的技术)
- 使用读写锁,也就是读是无锁的,只有写的时候才会互斥
性能方面的度量指标有很多,有三个指标非常重要:
- 吞吐量:指的是单位时间内能处理的请求数量。吞吐量越高,说明性能越好。
- 延迟:指的是从发出请求到收到响应的时间。延迟越小,说明性能越好。
- 并发量:指的是能同时处理的请求数量,一般来说随着并发量的增加、延迟也会增加。所以延迟这个指标,一般都会是基于并发量来说的。例如并发量是 1000 的时候,延迟是 50 毫秒。