高并发之——并发编程中必须注意的三大核心问题

开门见山

编写高并发程序确实不容易,需要我们注意很多高并发环境下的问题。这些问题总结起来,可以归纳为三个方面,分别是安全性问题、活跃性问题和性能问题。接下来,我们就围绕着并发编程中的这三大核心问题展开深入探讨。

安全性问题

问题分析

我们在实际的工作过程中,可能会经常听到这样的描述:你写的这个方法不是线程安全的,这个类不是线程安全的!而且我们在《高并发之——并发编程诡异的问题源头》一文中,详细描述了并发编程中诡异问题的源头,也就是缓存导致的可见性问题、线程切换带来的原子性问题和编译优化带来的有序性问题。

既然编写并发程序可能会出现这三个问题,那我们是不是需要对所有的程序进行检查呢,看其是否存在上述三个问题呢?

其实,我们无需对所有的代码都分析是否存在这三个问题,只需要对存在共享数据并且共享的数据会发生变化(也就是说,存在多个线程同时读写同一数据)的程序进行分析就可以了。

换句话说,如果我们能够做到不共享数据或者数据的状态不发生变化,就能够保证线程是安全的了。其实,有很多方案都是这样实现的。例如:线程本地存储(Thread Local Storage,TLS)、不变模式等。

在实际工作中,数据往往是共享的,而且数据的状态是会发生变化的。例如,我们在编写并发程序时,经常会使多个线程同时访问同一数据,而且至少会有一个线程对这个数据进行写操作。此时,如果我们不采取防护措施,那就容易导致并发Bug的问题,在并发编程中,对此还有一个专业的术语,叫做数据竞争。例如,多个线程同时调用下面的方法时,就会发生数据竞争的情况。

public class ThreadTest{
    private long count = 0;
    public void incrementCount(){
        count++;
    }
}

既然上面的程序不是线程安全的,我们是不是只要对其加锁就可以解决并发问题呢?到底是不是这样呢?

接下来,我们对上面的程序进行改造,增加一个使用synchronized修饰的setCount()方法和一个使用synchronized修饰的getCount()方法,incrementCount()方法中通过setCount()方法和getCount()方法来访问count变量,代码如下所示。

public class ThreadTest{
    private long count = 0;
    public synchronized void setCount(long count){
        this.count = count;
    }
    public synchronized long getCount(){
        return this.count;
    }
    public void incrementCount(){
        set(getCount()++);
    }
}

对于修改后的代码,所有访问共享变量count的地方,都增加了互斥锁,此时,程序中不存在数据竞争,但是很显然incrementCount()方法不是线程安全的。

假设count=0,当两个线程同时执行getCount()方法时,getCount()方法会返回相同的值0,两个线程同时执行getCount()++操作,得出的结果是1,然后两个线程再将结果1写入内存。本来期望的值为2,而结果却是1。

这种现象的官方名称叫竞态条件,它是指执行的结果数据依赖于线程执行的顺序。以ThreadTest类的incrementCount()方法来说,如果两个线程完全同时执行,则结果为1。如果两个线程存在先后顺序,则结果为2。在并发编程中,线程的执行顺序不太确定,此时,如果程序存在竞态条件问题,那就意味着程序执行的结果是不确定的,这就存在一个巨大的Bug了!!!

对于我们前面介绍过的转账操作,如果不加控制,也会出现竞态条件。例如下面的转账类。

public class TansferAccount{
    private Integer balance;
    public void transfer(TansferAccount target, Integer transferMoney){
        if(this.balance >= transferMoney){
            this.balance -= transferMoney;
            target.balance += transferMoney;
        }
    }
}

假设账户A的余额为200,线程A和线程B同时从账户A中转出200,如果两个线程同时执行到下面一行代码时,

if(this.balance >= transferMoney){

两个线程发现账户A的余额大于等于要转出的金额,条件成立,进而执行转账操作,这种情况下,账户A的余额只有200,却能够向外转出400。这就是竞态条件造成的问题。

我们也可以这样理解竞态条件,在并发场景下,程序的执行依赖于某个状态变量,如下伪代码所示。

if(状态变量 满足 执行条件){
    执行相应的操作
}

某个线程发现状态变量满足执行条件,开始执行操作;如果在这个线程执行操作的时候,其他线程同时修改了状态变量,此时,状态变量就不满足执行的条件的。有时,这种执行的条件不是显式的,例如,ThreadTest类中的incrementCount()方法中的 set(getCount()++)操作,就隐式的依赖了getCount()的结果数据。

问题解决

既然数据竞争和竞争条件会导致这么多的问题,那我们如何保证线程的安全性呢?其实,这两类问题归根结底都是互斥问题,我们可以使用互斥锁来解决这些问题。关于互斥锁的使用,大家可以参考【高并发专题】中,前面的文章。

活跃性问题

问题分析

活跃性问题,值得是某个操作无法执行下去了。典型的场景有:死锁、活锁和饥饿。

死锁

死锁会表现为线程之间互相等待,一直阻塞。

活锁

有时线程虽然没有发生阻塞,但是会存在执行不下去的情况,这种情况被称为活锁。例如,线程A和线程B同时争抢资源A,发现无法抢到资源A时,放弃抢占资源A,又去同时抢夺资源B,发现资源B也无法抢到,又去同时争抢资源A,如此反复,造成了活锁。

饥饿

线程因无法访问所需的资源而无法执行下去,这种情况就叫做饥饿。如果线程之间存在明显的优先级问题,则在CPU繁忙的情况下,优先级低的线程得到执行的机会就很小了,此时可能发生线程饥饿;如果持有锁的线程执行的时间过长,也会导致饥饿问题。

问题解决

死锁

如果发生真的发生了死锁问题,一般需要重启应用来解决,所以,我们一定要尽量避免死锁问题的发生。

发生死锁问题时,一定存在死锁的四个必要条件,我们破坏任意一个条件即可避免死锁,具体参见《高并发之——程序死锁了怎么办?》一文。

活锁

解锁活锁问题时,就是让线程随机等待一小段时间,这样同时争抢另一个资源的概率就小多了。这种解决活锁的方式,典型的应用场景就是Raft分布式一致性算法。

饥饿

解决饥饿问题总体来说有三种方案,可以如下所示。

  • 提供充足的系统资源。
  • 避免持有锁的线程长时间运行。
  • 公平的分配资源。

这里,其实很多时候,我们无法为线程的执行提供充足的系统资源,也没有办法决定线程执行时间的长短。所以,我们可以尽量为线程公平的分配资源,可以使用公平锁来为线程公平的分配资源。公平锁从本质上来讲,能够保证线程的等待是有顺序的,排在等待队列前面的线程会优先获得系统资源。

性能问题

锁使用不当,也会出现性能问题。如果在程序中使用锁过度,会导致程序串行执行的范围过大,严重影响程序的性能。

所以,如果我们需要解决性能问题,就要尽量减少程序的串行,我们可以使用阿姆达尔定律来代表处理器并行运算之后提升的性能。

阿姆达尔定律

该定律是指:系统中对某一部件采用更快执行方式,所能获得的系统性能改进程度,取决于这种执行方式被使用的频率,或所占总执行时间的比例。

阿姆达尔定律实际上定义了采取增强(加速)某部分功能处理的措施后,可获得的性能改进或执行时间的加速比。

阿姆达尔曾致力于并行处理系统的研究。对于固定负载情况下,描述并行处理效果的加速比s,阿姆达尔经过深入研究给出了如下公式:

S=1/(1-a+a/n)

其中,a为并行计算部分所占比例,n为并行处理结点个数。

这样,当1-a=0时,(即没有串行,只有并行),最大加速比s=n;当a=0时(即只有串行,没有并行),最小加速比s=1;当n→∞时,极限加速比s→ 1/(1-a),这也就是加速比的上限。

例如,若串行代码占整个代码的25%,则并行处理的总体性能不可能超过4。这一公式已被学术界所接受,并被称做“阿姆达尔定律”,也称为“安达尔定理”(Amdahl law)。

问题解决

既然使用锁会带来性能问题,那最好的方案就是使用无锁的算法和数据结构。例如使用线程本地存储(Thread Local Storage, TLS)、写入时复制(Copy-on-write)、乐观锁等;java.util.concurrent包中的原子类就是一种无锁的数据结构,另外,也可以使用Disruptor这个无锁的内存队列,性能不错。

我们也可以尽量减少锁持有的时间。互斥锁的本质就是将并行程序串行化,所有如果要增加并行度,一定要减少线程持有锁的时间。例如,我们可以使用读写锁,读时无锁,写时互斥。另外,Java 8之前的ConcurrentHashMap使用了分段锁技术,我们也可以借鉴下ConcurrentHashMap的实现。

总体来说,以下三个重要指标能够评估系统的性能:

  • 吞吐量:单位时间内能处理的请求数量,吞吐量越高,性能越好;
  • 延迟:从发出请求到收到响应的时间,延迟越小,性能越好;
  • 并发量:同时处理的请求数量。一般并发量越大,延迟也就越大,所以通常所说的延迟,一般都是基于并发量来说的。例如,并发量是2000的时候,延迟为100毫秒。

总结

我们在编写并发程序时,必须关注程序的安全性、活跃性和性能问题。

  • 安全性方面我们需要注意数据竞争和竞态条件;

  • 活跃性方面我们需要注意死锁、活锁和饥饿问题;

  • 性能方面我们需要根据具体的场景选择合适的数据结构和算法。

发布了1338 篇原创文章 · 获赞 2086 · 访问量 524万+

猜你喜欢

转载自blog.csdn.net/l1028386804/article/details/104785113