深入底层探究并发编程Bug罪魁祸首——可见性、原子性、有序性

【深入底层探究并发编程Bug罪魁祸首——可见性、原子性、有序性】如果你细心观察的话,你会发现,不管是哪一门编程语言,并发类的知识都是在高级篇里 。换句话说,这块知识点其实对于程序员来说,是比较进阶的知识 。我自己这么多年学习过来,也确实觉得并发是比较难的,因为它会涉及到很多的底层知识,比如若你对操作系统相关的知识一无所知的话,那去理解一些原理就会费些力气 。
大家都知道,编写正确的并发程序是一件极困难的事情,并发程序的 Bug 往往会诡异地出现,然后又诡异地消失,很难重现,也很难追踪,很多时候都让人很抓狂 。但要快速而又精准地解决“并发”类的疑难杂症,你就要理解这件事情的本质,追本溯源,深入分析这些Bug 的源头在哪里 。
那为什么并发编程容易出问题呢?它是怎么出问题的?今天我们就重点聊聊这些 Bug 的源头 。
并发程序幕后的故事这些年,我们的 CPU、内存、I/O 设备都在不断迭代,不断朝着更快的方向努力 。但是,在这个快速发展的过程中,有一个核心矛盾一直存在,就是这三者的速度差异 。CPU 和内存的速度差异可以形象地描述为:CPU 是天上一天,内存是地上一年(假设 CPU 执行一条普通指令需要一天,那么 CPU 读写内存得等待一年的时间) 。内存和 I/O 设备的速度差异就更大了,内存是天上一天,I/O 设备是地上十年 。
程序里大部分语句都要访问内存,有些还要访问 I/O,根据木桶理论(一只水桶能装多少水取决于它最短的那块木板),程序整体的性能取决于最慢的操作——读写 I/O 设备,也就是说单方面提高 CPU 性能是无效的
为了合理利用 CPU 的高性能,平衡这三者的速度差异,计算机体系机构、操作系统、编译程序都做出了贡献,主要体现为:

  1. 1. CPU 增加了缓存,以均衡与内存的速度差异;
  2. 2. 操作系统增加了进程、线程,以分时复用 CPU,进而均衡 CPU 与 I/O 设备的速度差异;
  3. 3. 编译程序优化指令执行次序,使得缓存能够得到更加合理地利用 。
现在我们几乎所有的程序都默默地享受着这些成果,但是天下没有免费的午餐,并发程序很多诡异问题的根源也在这里 。
源头之一:缓存导致的可见性问题在单核时代,所有的线程都是在一颗 CPU 上执行,CPU 缓存与内存的数据一致性容易解决 。因为所有线程都是操作同一个 CPU 的缓存,一个线程对缓存的写,对另外一个线程来说一定是可见的 。例如在下面的图中,线程 A 和线程 B 都是操作同一个 CPU 里面的缓存,所以线程 A 更新了变量 V 的值,那么线程 B 之后再访问变量 V,得到的一定是 V 的最新值(线程 A 写过的值) 。
深入底层探究并发编程Bug罪魁祸首——可见性、原子性、有序性

文章插图
CPU 缓存与内存的关系图
一个线程对共享变量的修改,另外一个线程能够立刻看到,我们称为可见性 。
多核时代,每颗 CPU 都有自己的缓存,这时 CPU 缓存与内存的数据一致性就没那么容易解决了,当多个线程在不同的 CPU 上执行时,这些线程操作的是不同的 CPU 缓存 。比如下图中,线程 A 操作的是 CPU-1 上的缓存,而线程 B 操作的是 CPU-2 上的缓存,很明显,这个时候线程 A 对变量 V 的操作对于线程 B 而言就不具备可见性了 。这个就属于硬件程序员给软件程序员挖的“坑” 。
深入底层探究并发编程Bug罪魁祸首——可见性、原子性、有序性

文章插图
多核 CPU 的缓存与内存关系图
下面我们再用一段代码来验证一下多核场景下的可见性问题 。下面的代码,每执行一次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 的 。这就是缓存的可见性问题 。


推荐阅读