并发编程—可见性、原子性、有序性 BUG源头

目录

一、幕后的故事

二、三大源头

源头之一:缓存导致的可见性问题

源头之二:线程切换带来的原子性问题

源头之三:编译优化带来的有序性问题

三、总结


如果细心观察,你会发现,不管哪一门编程语言,并发类的知识都是在高级篇里。也就是说,并发编程这块知识对于程序员来说,是比较进阶的知识。因为并发编程的知识会涉及到很多底层的知识,比如操作系统,有的可能还涉及到硬件CPU的知识。

我们都知道,编写一个正确的并发程序是一件极其困难的事情,并发程序的问题往往很诡异的出现,让后有诡异的消失,很难跟踪,又极难复现,让很多人为之抓狂。要想能够速度而又精准地解决“并发”类的疑难杂症,就要搞清楚导致这些问题的本质、追本溯源深入分析这些Bug的源头。

一、幕后的故事

随着CPU、内存、I/O设备的不断更新迭代,不断朝着更快的方向努力。但是不管怎么发展都存在一个核心问题,就是这三者的速度差异。形象的描述着三者的速度差异,可谓是 CPU天上一日,内存地上一年;内存天上一日,I/O设备地上十年。

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

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

天下没有免费的午餐,这三个方面的优化,也成了并发程序很多诡异问题的根源所在地。

二、三大源头

源头之一:缓存导致的可见性问题

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

在单核时代,所有的线程都在一颗CPU上执行,CPU缓存与内存的数据一致性很容易解决,因为所有的线程都操作同一个CPU的缓存,一个线程的写,对另一个线程的读一定是可见的。如下图所示:

单核CPU与内存关系

 由此可知,单核时代不存在可见性问题。

但是,现在的CPU都是多核的了,在多核时代,每个CPU内核都有自己的缓存,这时CPU缓存与内存数据一致性就没有那么容易解决了,不同的线程在不同的CPU上执行,这样不同的线程操作的是不同CPU的缓存,这样就导致了一个线程对共享变量的修改,另一个线程不一定可见,也就不具备了可见性。模型如下所示:

多核CPU与内存的关系

 比如X是个计数器变量,在内存中的值为0,线程A和线程B同时读取了变量值0保存到了各自的缓存中,线程A对 X变量做 加1操作,线程B也对X变量做了加1操作,理论上线程A和线程B操作后X值应该是2,但是由于各自操作各自的缓存,计算后导致内存X变量的值为1。这是由于一个线程对变量的修改,对另一个线程不可见导致的。

在开发并发程序时解决了共享变量的可见性,可以避免并发程序中的部分诡异问题。如何保证共享变量的可见性呢?Java可以通过 锁、volatile修饰的变量。

源头之二:线程切换带来的原子性问题

定义:一个或者多个操作在CPU执行的过程中不被中断的特性称之为原子性

操作系统是通过时间轮片的方式运行多个线程的,也就是这个线程运行一段时间,切换到另个线程运行一段时间,如下所示:

线程切换示意图

 Java并发程序都是基于多线程的,日然也就涉及到任务切换,那么任务切换究竟是怎样引发并发编程诡异问题出现的呢?

我们现在基本使用的都是高级语言编程,高级语言里的一条语句往往需要多条CPU指令完成,例如我们在编程中经常出现的 i += 1, i++ 等操作,至少需要三条CPU指令。

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

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

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

操作系统做切换,可能发生在上面任何一条CPU指令执行完,是的,CPU指令,而不是高级语言里的一条语句。对于上面三条指令来说,开始 i = 0,线程A在执行完第一条指令后做线程切换到线程B,线程B执行完上面三条指令后,又切换回了线程A,接着执行指令2和指令2,执行后就会覆盖线程B已经写入的值。执行如下所示:

非原子操作执行路径示意图

 根据原子性的定义可知 i+=1,i++ 等都不为原子性操作。所有在并发编程中保证 一条语句不被中断,那么就能避免一部分的并发诡异问题。在Java中可以通过加锁、CAS和原子对象等实现原子性操作。

源头之三:编译优化带来的有序性问题

定义:有序性是指程序按照代码的先后顺序执行。

有序性是一种违背直觉的诡异性问题,那么是什么导致的有序性问题呢?

编译器和处理器为了优化程序性能而对指令序列进行重新排序,也就是说为了性能能在不影响最终结果(单线程中)的前提下编译器和处理器会对指令进行重排序。

编译器和处理器在重排序时,会遵守数据依赖性,编译器和处理器不会改变存在数据依赖关系的两个操作的执行顺序。这里所说的数据依赖性仅针对单个处理器中执行的指令序列和单个线程中执行的操作,不同处理器之间和不同线程之间的数据依赖性不被编译器和处理器考虑。

如下所示:

int a = 6;

int b = 7;

在编译器优化后可能变为

int b = 7;

int a = 6;

 在这个例子中,编译器调整了语句顺序,但是不影响最终的结果。不过在并发编程中编译器或处理器对指令的重排序会影响最终的结果。

比如在Java领域中的经典案例,双重检查创建单例模式


public class Singleton {
  static Singleton instance;
  static Singleton getInstance(){
    if (instance == null) {  // ①
      synchronized(Singleton.class) { // ②
        if (instance == null)  // ③
          instance = new Singleton();  // ④
        }
    }
    return instance;
  }
}

假设练个线程 A、B 调用 getInstance()方法,线程A①处发现 instance == null,于是执行到了②处对Singleton.class加锁,线程A执行到③处时发现instance == null,于是执行④处创建对象,此时CPU切换到了线程B执行,线程B调用getInstance()走到了①,直觉是 当时 instance 还是 等于 null。但是在Java中的 new 操作应该是如下:

  1. 分配一块内存 M;

  2. 在内存 M 上初始化 Singleton 对象;

  3. 然后 M 的地址赋值给 instance 变量。

但是实际上new操作的执行路径可能是这样的:

  1. 分配一块内存 M;

  2. 然后 M 的地址赋值给 instance 变量;

  3. 在内存 M 上初始化 Singleton 对象;

如果线程A在new操作时发生了重排序,在指向到  2.然后 M 的地址赋值给 instance 变量;还未执行3时,切换到了线程B执行,那么线程B在①处看到的 instance 可能不为null,所以直接返回了 还未初始化的 instance 对象,那么在调用时就有可能出现 空指针异常。

三、总结

要写好并发程序,首先搞清楚并发程序的问题在哪里,只有确定了“靶子”,才有可能把问题解决,比较所有的解决方案都是针对问题的。并发程序经常出现的诡异问题看上去无厘头,深究的话,都是直觉欺骗了我们,只要我们深刻理解并掌握了可见性、原子性、有序性在并发场景下的原理,很多并发问题都可以理解、可以诊断的。

缓存导致了可见性问题,线程切换带来了原子性问题,编译优带来了有序性问题。缓存、切换、编译优化的目的也是为了提高程序的性能。但是技术往往就是解决一个问题的同时,必然会带来另一个问题,所以在采用一项技术的同时,一定要弄清楚他的带来的问题是什么,以及如何规避


参考资料:

   Java并发编程实战

  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值