并发编程 原子性、可见性、有序性

我们都知道编写并发编程是一件很困哪的事情,并发编程的Bug往往会很诡异地出现,然后又诡异的消失,很难重现也很难追踪,很多时候让人抓狂,但有快速有精准的解决这些Bug,就需要理解这些事情本质,追本溯源,深入分析这些Bug的源头在哪。

虽然我们的CPU、内存、I/O设备不断的在迭代,但他们三者的速度差异始终存在。CPU和内存的速度差异,内存和I/O设备的速度差异。我们的程序不仅需要访问内存还需要访问I/O设备。根据木桶理论,程序的整体性能取决于最慢的操作,即读写I/O设备,也就是单方面提升CPU的速度是无效的。为了合理利用CPU提高性能,平衡这三者之间的差异,计算机体系结构、操作系统、编译程序都作出了贡献,主要为:

  • 内存的速度相对于CPU慢很多,于是CPU增加了缓存,用来均衡与内存的速度差异
  • 操作系统增加了进程和线程,以分时复用CPU,进而均衡CPU和I/O设备的速度差异
  • 编译程序优化程序的执行指令,使得缓存能够得到更合理的使用
    我们的程序享受着这些背后优化的成果,但同时也能够引发一些问题,并发编程问题的根源就来于上面几个点。

源头一:缓存导致程序可见性

可见性:指的是一个线程对一个变量的更新,另一个线程立即能够读取到最新的值。

在单核CPU时代,所有的程序都在一颗CPU上执行,CPU与内存数据的一致性能够得到解决。因为所有的线程都是操作同一个CPU的缓存,一个线程对缓存的写,另一个线程一定能够可见。下图线程A更新了变量V的值,线程B也能够得到最新V的值。


CPU 缓存与内存的关系图

在多核CPU时代,每颗CPU都有自己的缓存。线程A更新的是CPU-1上的变量V的值,线程B更新的CPU-2上变量V的值。很明显,此时这时候变量V的值对于线程A和线程B已经不具备可见性了。因为把CPU缓存更新的值刷新到内存有一定的时间间隔,这段期间其他线程访问变量V的值就不是拿到最新更新的值。

多核 CPU 的缓存与内存关系图

测试代码:

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;
  }
}

单线程两次调用add10K()方法返回的值是20000。但两个线程同时执行得到的结果却是10000-20000之间的数。这是为什么呢?
我们假设线程A和B同时执行,那么第一次都会将count=0读到自己的CPU缓存里,执行完cout+=1后更新CPU缓存的值,各自的CPU缓存的值就都是1,同时写入内存后内存值是1,而不是我们期望值2。之后各自CPU缓存里面都缓存了count的值,都是基于各自的count缓存值来计算,所以导致最终count的值是小于20000的,这就是缓存可见性问题。

变量 count 在 CPU 缓存和内存的分布图

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

由于IO太慢,于是早期的操作系统发明了多进程,这样在单核CPU上也能够同时运行多个任务。操作系统允许某个线程执行一小段时间,例如50ms,过了50ms操作系统就会重新选择一个进程来执行(即任务切换),这个50ms称为"时间片"。


线程切换示意图

在一个时间片内,如果一个进程在进行一个IO操作,例如读取文件,这个时刻进程可以把自己标记位休眠状态并让出CPU的使用权,待文件读进内存,操作系统系统会把这个休眠的线程唤醒,唤醒后就有机会重新获得CPU的使用权了。这里进程在等待IO时之所以会释放CPU使用权,是为了让CPU在这段时间可以做别的事情,这样一来CPU的利用率就上来了。此外,如果这时有另外一个进程也在读文件,读文件的操作就会排队,磁盘驱动完成一个进程的读操作后,发现有排队的任务,就会立即启动下一个读操作,这样IO利用率也上来了。

早起的操作系统都是基于进程来调度CPU,不同的进程是不共享内存空间的,所以进程切换要做任务切换的就是切换内存映射地址。而进程创建的线程都是共享一个内存空间的,所以线程做任务切换成本就很低了。现代的操作系统都是基于更轻量级的线程来做调度,现在我们提到的“任务切换”都是指的"线程切换"。

java的并发都是基于多线程的,自然也会涉及到任务切换,任务切换的时机大多数是在时间片结束的时候,我们现在都使用高级语言编程,高级语言里一条语句往往需要多条CPU指令完成。例如count +=1,至少需要三条CPU指令。

  • 指令1:首先,需要把count的值从内存加载到CPU的寄存器
  • 指令2:之后,在寄存器执行+1操作
  • 指令3:最后将结果写入内存,缓存机制导致可能写入的是CPU缓存而不是内存。

操作系统做任务切换,可以发生在任何一条CPU指令,我们假设count=0,如果线程A在指令1执行完后做线程切换,线程A和线程B按照下图的序列执行,那么我们会发现两个线程都执行了count+=1的操作,但是得到的结果不是我们期望的2,而是1。


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

我们潜意识觉得count+=1这个操作是一个不可分割的整体,就想一个院子一样,线程的切换可以发生在count+=1之前,也可能在之后,而不会发生在之间。我们把一个或者多个操作在CPU的执行过程中不被终端的特性称为原子性。CPU保证的是原子操作是CPU指令级别的,而不是高级语言操作符,这是违背我们直觉的地方,因此,很多时候我们需要再高级语言层面保证操作的原子性。

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

有序性值的是按照代码先后顺序执行。编译器为了优化性能,有的时候就会改变程序的执行顺序。例如"a=6;b=7"编译器优化后就可能变成"a=7;b=6"这个例子,编译器调整了顺序,但是不影响程序的最终执行结果。不过编译器的优化可能导致意想不到的Bug。
在java有一个经典的案例就是利用双重检查去创建单例对象。

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

上面的代码看上去没有问题,但是getInstance()方法并不完美。问题出现在new操作上,我们认为的new操作应该是:
1.分配一块内存
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成员变量就可能会触发空指针异常。

双重检查创建单例的异常执行路径

java里面解决办法:

  • 使用volatile保证内存可见性,每次对变量的写操作都从CPU缓存刷新到内存里面,这样每次读取变量的值一定是最新更新的。变量声明如下:
  static volatile Singleton instance;
  • final关键字修饰。当一个对象包含final修饰的字段时,其他线程能够看到已经初始化的final实例字段,这是线程安全的。
final static Singleton instance;

你可能感兴趣的:(并发编程 原子性、可见性、有序性)