01 | 可见性、原子性和有序性问题:并发编程Bug的源头

 因为CPU、内存、I/O 设备的速度差别,为了合理利用 CPU 的高性能,平衡这三者的速度差别,计算机体系机构、操做系统、编译程序都作出如下处理:java

1. CPU 增长了缓存,以均衡与内存的速度差别;
2. 操做系统增长了进程、线程,以分时复用 CPU,进而均衡 CPU 与 I/O 设备的速度差别;
3. 编译程序优化指令执行次序,使得缓存可以获得更加合理地利用。 
 
源头之一:缓存致使的可见性问题
 
在单核时代,全部的线程都是在一颗 CPU 上执行,CPU 缓存与内存的数据一致性容易解决。由于全部线程都是操做同一个 CPU 的缓存,一个线程对缓存的写,对另一个线程来讲必定是可见的。
 
一个线程对共享变量的修改,另一个线程可以马上看到,咱们称为 可见性
 
多核时代,每颗 CPU 都有本身的缓存,这时 CPU 缓存与内存的数据一致性就没那么容易解决了,当多个线程在不一样的 CPU 上执行时,这些线程操做的是不一样的 CPU 缓存。好比下图中,线程 A 操做的是 CPU-1 上的缓存,而线程 B 操做的是 CPU-2 上的缓存,很明显,这个时候线程 A 对变量 V 的操做对于线程 B 而言就不具有可见性了。这个就属于硬件程序员给软件程序员挖的“坑”。
 
下面咱们再用一段代码来验证一下多核场景下的可见性问题。下面的代码,每执行一次add10K() 方法,都会循环 10000 次 count+=1 操做。在 calc() 方法中咱们建立了两个线程,每一个线程调用一次 add10K() 方法,咱们来想想执行 calc() 方法获得的结果应该是多少呢? 
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,由于在单线程里调用两次 add10K() 方法,count 的值就是20000,但实际上 calc() 的执行结果是个 10000 到 20000 之间的随机数。为何呢?
咱们假设线程 A 和线程 B 同时开始执行,那么第一次都会将 count=0 读到各自的 CPU缓存里,执行完 count+=1 以后,各自 CPU 缓存里的值都是 1,同时写入内存后,咱们会发现内存中是 1,而不是咱们指望的 2。以后因为各自的 CPU 缓存里都有了 count 的值,两个线程都是基于 CPU 缓存里的 count 值来计算,因此致使最终 count 的值都是小于 20000 的。这就是缓存的可见性问题。 循环 10000 次 count+=1 操做若是改成循环 1 亿次,你会发现效果更明显,最终 count的值接近 1 亿,而不是 2 亿。若是循环 10000 次,count 的值接近 20000,缘由是两个线程不是同时启动的,有一个时差。 
变量 count 在 CPU 缓存和内存的分布图
 
源头之二:线程切换带来的原子性问题 
因为 IO 太慢,早期的操做系统就发明了多进程,即使在单核的 CPU 上咱们也能够一边听着歌,一边写 Bug,这个就是多进程的功劳。 操做系统容许某个进程执行一小段时间,例如 50 毫秒,过了 50 毫秒操做系统就会从新选择一个进程来执行(咱们称为“任务切换”),这个 50 毫秒称为“时间片”。在一个时间片内,若是一个进程进行一个 IO 操做,例如读个文件,这个时候该进程能够把本身标记为“休眠状态”并出让 CPU 的使用权,待文件读进内存,操做系统会把这个休眠的进程唤醒,唤醒后的进程就有机会从新得到 CPU 的使用权了。这里的进程在等待 IO 时之因此会释放 CPU 使用权,是为了让 CPU 在这段等待时间里能够作别的事情,这样一来 CPU 的使用率就上来了;此外,若是这时有另一个进程也读文件,读文件的操做就会排队,磁盘驱动在完成一个进程的读操做后,发现有排队的任务,就会当即启动下一个读操做,这样 IO 的使用率也上来了。
 
是否是很简单的逻辑?可是,虽然看似简单,支持多进程分时复用在操做系统的发展史上却具备里程碑意义,Unix 就是由于解决了这个问题而名噪天下的。 早期的操做系统基于进程来调度 CPU,不一样进程间是不共享内存空间的,因此进程要作任务切换就要切换内存映射地址,而一个进程建立的全部线程,都是共享一个内存空间的,因此线程作任务切换成本就很低了。现代的操做系统都基于更轻量的线程来调度,如今咱们提到的“任务切换”都是指“线程切换”。 
 
Java 并发程序都是基于多线程的,天然也会涉及到任务切换,也许你想不到,任务切换居然也是并发编程里诡异 Bug 的源头之一。任务切换的时机大多数是在时间片结束的时候,咱们如今基本都使用高级语言编程,高级语言里一条语句每每须要多条 CPU 指令完成,例如上面代码中的count += 1,至少须要三条 CPU 指令。 
指令 1:首先,须要把变量 count 从内存加载到 CPU 的寄存器;
指令 2:以后,在寄存器中执行 +1 操做;
指令 3:最后,将结果写入内存(缓存机制致使可能写入的是 CPU 缓存而不是内存)。

  

操做系统作任务切换,能够发生在任何一条CPU 指令执行完,是的,是 CPU 指令,而不是高级语言里的一条语句。对于上面的三条指令来讲,咱们假设 count=0,若是线程 A在指令 1 执行完后作线程切换,线程 A 和线程 B 按照下图的序列执行,那么咱们会发现两个线程都执行了 count+=1 的操做,可是获得的结果不是咱们指望的 2,而是 1。 
                                                               非原子操做的执行路径示意图
 
咱们潜意识里面以为 count+=1 这个操做是一个不可分割的总体,就像一个原子同样,线程的切换能够发生在 count+=1 以前,也能够发生在 count+=1 以后,但就是不会发生在中间。 咱们把一个或者多个操做在 CPU 执行的过程当中不被中断的特性称为原子性
 
CPU 能保证的原子操做是 CPU 指令级别的,而不是高级语言的操做符,这是违背咱们直觉的地方。所以,不少时候咱们须要在高级语言层面保证操做的原子性。
 
源头之三:编译优化带来的有序性问题 
那并发编程里还有没有其余有违直觉容易致使诡异 Bug 的技术呢?有的,就是有序性。顾名思义,有序性指的是程序按照代码的前后顺序执行。编译器为了优化性能,有时候会改变程序中语句的前后顺序,例如程序中:“a=6;b=7;”编译器优化后可能变成“b=7;a=6;”,在这个例子中,编译器调整了语句的顺序,可是不影响程序的最终结果。不过有时候编译器及解释器的优化可能致使意想不到的 Bug。在 Java 领域一个经典的案例就是利用双重检查建立单例对象,例以下面的代码:在获取实例 getInstance() 的方法中,咱们首先判断 instance 是否为空,若是为空,则锁定Singleton.class 并再次检查 instance 是否为空,若是还为空则建立 Singleton 的一个实例。 
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() 方法,他们会同时发现 instance ==null ,因而同时对 Singleton.class 加锁,此时 JVM 保证只有一个线程可以加锁成功(假设是线程 A),另一个线程则会处于等待状态(假设是线程 B);线程 A 会建立一个 Singleton 实例,以后释放锁,锁释放后,线程 B 被唤醒,线程 B 再次尝试加锁,此时是能够加锁成功的,加锁成功后,线程 B 检查 instance == null 时会发现,已经建立过 Singleton 实例了,因此线程 B 不会再建立一个 Singleton 实例。 这看上去一切都很完美,无懈可击,但实际上这个 getInstance() 方法并不完美。问题出在哪里呢?出在 new 操做上,咱们觉得的 new 操做应该是:
1. 分配一块内存 M;
2. 在内存 M 上初始化 Singleton 对象;
3. 而后 M 的地址赋值给 instance 变量。
可是实际上优化后的执行路径倒是这样的:
1. 分配一块内存 M;
2. 将 M 的地址赋值给 instance 变量;
3. 最后在内存 M 上初始化 Singleton 对象。
优化后会致使什么问题呢?咱们假设线程 A 先执行 getInstance() 方法,当执行完指令 2时刚好发生了线程切换,切换到了线程 B 上;若是此时线程 B 也执行 getInstance() 方法,那么线程 B 会发现instance != null,因此直接返回 instance,而此时的
instance 是没有初始化过的,若是咱们这个时候访问 instance 的成员变量就可能触发空指针异常。 
双重检查建立单例的异常执行路径
 
总结
要写好并发程序,首先要知道并发程序的问题在哪里,只有肯定了“靶子”,才有可能把问题解决,毕竟全部的解决方案都是针对问题的。并发程序常常出现的诡异问题看上去很是无厘头,可是深究的话,无外乎就是直觉欺骗了咱们,只要咱们可以深入理解可见性、原子性、有序性在并发场景下的原理,不少并发 Bug 都是能够理解、能够诊断的。在介绍可见性、原子性、有序性的时候,特地提到缓存致使的可见性问题,线程切换带来的原子性问题,编译优化带来的有序性问题,其实缓存、线程、编译优化的目的和咱们写并发程序的目的是相同的,都是提升程序性能。可是技术在解决一个问题的同时,必然会带来另一个问题,因此在采用一项技术的同时,必定要清楚它带来的问题是什么,以及如何规避。
 
常听人说,在 32 位的机器上对 long 型变量进行加减操做存在并发隐患,究竟是不是这样呢?
long类型64位,因此在32位的机器上,对long类型的数据操做一般须要多条指令组合出来,没法保证原子性,因此并发的时候会出问题
相关文章
相关标签/搜索