并发程序幕后的故事

一个核心矛盾:cpu、内存、I/O设备的速度差异

cpu的速度是内存的100倍,

内存是I/O的100倍。

而程序大部分语句要访问内存,有些还要访问I/O,所以程序整体的性能屈居于最慢的操作—读写I/O设备。单方面提高cpu性能是无效的。

为了平衡这三者的速度差异,计算机体系结构,操作系统,编译程序都做出了贡献:

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

但是,并发程序很多问题的根源也在这里。

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

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

在单核时代,cpu缓存与内存数据的一致性很好解决。

在多核时代,每个cpu都有自己的缓存,这时 CPU 缓存与内存的数据一致性就没那么容易解决了,当多个线程在不同的 CPU 上执行时,这些线程操作的是不同的 CPU 缓存。比如下图中,线程 A 操作的是 CPU-1 上的缓存,而线程 B 操作的是 CPU-2 上的缓存,很明显,这个时候线程 A 对变量 V 的操作对于线程 B 而言就不具备可见性了。这个就属于硬件程序员给软件程序员挖的“坑”。

image-20220826144959349

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

由于IO太慢,早期的操作系统就发明了多进程,即使在单核的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。

image-20220826154247317