你的并发程序为何会出诡异bug?

众所周知,编写好并发程序往往不是一件容易的事,常常会出一些十分诡异的bug。事实上,要理解其根本原因,需要从计算机底层的运行原理来探究。

并发问题三大原因

一,可见性问题

public class Test {
  private long count = 0;
  private void add10K() {
    int idx = 0;
    while(idx++ < 10000) {
      count += 1;
    }
  }
  public static long calc() {
    final Test test = new Test();
    // 创建两个线程,执行 add() 操作
    Thread th1 = new Thread(()->{
      test.add10K();
    });
    Thread th2 = new Thread(()->{
      test.add10K();
    });
    // 启动两个线程
    th1.start();
    th2.start();
    // 等待两个线程执行结束
    th1.join();
    th2.join();
    return count;
  }
}

上面这段代码的执行结果,我们会下意识的认为是20000。而实际结果却是10000—20000之间的某个随机数。

这是因为,我们以为的成员变量count对于两个线程是共享的,两个线程是无时不刻都获取的是同一个count

然而,事实上,由于现在几乎所有的计算机都是多核的,而每个cpu都有自己独立的cpu缓存,如果两个线程被分到两个cpu同时执行,那么它们各自从自己的缓存中获取到的count有可能是还没来得及同步的数据。于是就导致了两个线程的count并非总是我们以为的同一个。

图片发自简书App

一个线程对共享变量的修改,另外一个线程能够立刻看到,我们称为可见性。

由于cpu的缓存机制会导致多核cpu的并发场景时出现可见性问题

二,原子性问题

假设我们的计算机都是单核的,那就永远只会有一个cpu缓存,那么还可能会出现并发时的数据问题吗?

——是的,仍然可能。

上面那段代码,当程序执行count+=1这句话时,我们又会下意识的认为它是一个不可再被分割的整体,就像一个原子一样。

然而,实际上,这段代码在cpu中分为了三个指令来执行:

指令 1:首先,需要把变量 count 从内存加载到 CPU 的寄存器;

指令 2:之后,在寄存器中执行 +1 操作;

指令 3:最后,将结果写入内存(缓存机制导致可能写入的是 CPU 缓存而不是内存)。

并且每个线程拥有自己独立的寄存器。

图片发自简书App

从图中可以看出,当线程切换出现这种情况时,就会使得两个线程都执行完各自的一次运算后,count仍然为1而不是我们以为的2。

我们把一个或者多个操作在 CPU 执行的过程中不被中断的特性称为原子性。

线程的切换可能会打破高级编程语言中一个语句的(我们以为的)原子性,加上每个线程都拥有自己独立的寄存器,所以这又导致了并发时的数据共享的问题。

三,有序性问题

除了以上两种原因,还有一类原因也会引发并发的问题。

public class Singleton {
  static Singleton instance;
  static Singleton getInstance(){
    if (instance == null) {
      synchronized(Singleton.class) {
        if (instance == null)
          instance = new Singleton();
        }
    }
    return instance;
  }
}

以上代码就是经典的双检锁创建单例。

从代码层面分析,我们担心多个线程同时执行到 if (instance == null) ,于是在后面的创建实例的代码块加锁,并增加instance == null判断。

此时 JVM 保证只有一个线程能够加锁成功(假设是线程 A),另外一个线程则会处于等待状态(假设是线程 B);线程 A 会创建一个 Singleton 实例,之后释放锁,锁释放后,线程 B 被唤醒,线程 B 再次尝试加锁,此时是可以加锁成功的,加锁成功后,线程 B 检查 instance == null 时会发现,已经创建过 Singleton 实例了,所以线程 B 不会再创建一个 Singleton 实例。

单从代码分析似乎很完美。

但从cpu指令执行层面分析,不然。

我们以为的 new 操作应该是:

1.分配一块内存 M;
2.在内存 M 上初始化 Singleton 对象;
3.然后 M 的地址赋值给 instance 变量。

但是实际上cpu对指令优化后的执行路径却是这样的:

1.分配一块内存 M;
2.将 M 的地址赋值给 instance 变量;
3.最后在内存 M 上初始化 Singleton 对象。

如果线程1执行到第2步时发生了线程切换,轮到第二个线程执行getInstance()方法,线程2检查 instance == null就会发现instance不为空,于是直接返回一个空实例。

图片发自简书App

所以这就导致并发下可能会出现访问 instance 的成员变量就可能触发空指针异常。

编译器为了优化性能,可能会改变程序中语句的先后顺序,在并发场景下,有时就会导致意想不到的bug。

总结一下,导致并发编程诡异bug有三类原因。

1.缓存导致的可见性问题
2.线程切换带来的原子性问题
3.编译优化带来的有序性问题


都是性能优化带来的坑

由于cpu,内存,I/O 设备这三者的执行速度差异十分大,cpu最快,I/O 设备最慢。

为了合理利用 CPU 的高性能,平衡这三者的速度差异,计算机体系机构、操作系统、编译程序都做出了贡献,主要体现为:

1.CPU 增加了缓存,以均衡与内存的速度差异;
2.操作系统增加了进程、线程,以分时复用 CPU,进而均衡 CPU 与 I/O 设备的速度差异;
3.编译程序优化指令执行次序,使得缓存能够得到更加合理地利用。

而这三条优化正好对应了并发编程出诡异bug的三大原因。

——没有绝对好的解决方案,只有适合的场景。

我们在运用并发编程时一定要了解它背后可能藏着的坑。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
平台声明:文章内容(如有图片或视频亦包括在内)由作者上传并发布,文章内容仅代表作者本人观点,简书系信息发布平台,仅提供信息存储服务。

推荐阅读更多精彩内容