最近在学习极客时间的《并发编程实战》。我秉承着学习不仅是一个输入过程,输出也同等重要!下面大部分内容都是我在极客时间的笔记,但也有我的一些总结和见解。
并发程序幕后的故事
这些年,CPU、内存、I/O 设备都在不断迭代,不断朝着更快的方向努力。但是,在这个快速发展的过程中,有一个核心矛盾一直存在,就是这三者的速度差异。CPU 和内存的速度差异可以形象地描述为:CPU 是天上一天,内存是地上一年(假设 CPU 执行一条普通指令需要一天,那么 CPU 读写内存得等待一年的时间)。内存和 I/O 设备的速度差异就更大了,内存是天上一天,I/O 设备是地上十年。
程序里大部分语句都要访问内存,有些还要访问 I/O,根据木桶理论(一只水桶能装多少水取决于它最短的那块木板),程序整体的性能取决于最慢的操作——读写 I/O 设备,也就是说单方面提高 CPU 性能是无效的。为了合理利用 CPU 的高性能,平衡这三者的速度差异,计算机体系结构、操作系统、编译程序都做出了贡献,主要体现为:
- CPU 增加了缓存,以均衡与内存的速度差异(可见性)
- 操作系统增加了进程、线程,以分时复用 CPU,进而均衡 CPU 与 I/O 设备的速度差异(多线程,线程切换)
- 编译程序优化指令执行次序,使得缓存能够得到更加合理地利用(有序性)
现在我们几乎所有的程序都默默地享受着这些成果,但是天下没有免费的午餐,并发程序很多诡异问题的根源也在这里。
缓存导致的可见性问题
首先简单说一下什么是可见性:一个线程对共享变量的修改,另外一个线程能够立刻看到,我们称为可见性。
多核时代,每颗 CPU 都有自己的缓存,这时 CPU 缓存与内存的数据存在一致性问题,当多个线程在不同的 CPU 上执行时,这些线程操作的是不同的 CPU 缓存。比如下图中,线程 A 操作的是 CPU-1 上的缓存,而线程 B 操作的是 CPU-2 上的缓存,很明显,这个时候线程 A 对变量 V 的操作对于线程 B 而言就不具备可见性了。这个就属于硬件程序员给软件程序员挖的“坑”
线程切换带来的原子性问题
我们把一个或者多个操作在 CPU 执行的过程中不被中断的特性称为原子性
至于线程切换就不说了。
任务切换的时机大多数是在时间片结束的时候,然而我们现在基本都使用高级语言编程,高级语言里一条语句往往需要多条 CPU 指令完成,例如上面代码中的 count += 1
,至少需要三条 CPU 指令
- 指令 1:首先,需要把变量 count 从内存加载到 CPU 的寄存器;
- 指令 2:之后,在寄存器中执行 +1 操作;
- 指令 3:最后,将结果写入内存(缓存机制导致可能写入的是 CPU 缓存而不是内存)
操作系统做任务切换,可以发生在任何一条 CPU 指令执行完,是的,是 CPU 指令,而不是高级语言里的一条语句
我们潜意识里面觉得 count+=1
这个操作是一个不可分割的整体,就像一个原子一样,线程的切换可以发生在 count+=1
之前,也可以发生在 count+=1
之后,但就是不会发生在中间。CPU 能保证的原子操作是 CPU 指令级别的,而不是高级语言的操作符,这是违背我们直觉的地方。因此,很多时候我们需要在高级语言层面保证操作的原子性。
编译优化带来的有序性问题
顾名思义,有序性指的是程序按照代码的先后顺序执行。编译器为了优化性能,有时候会改变程序中语句的先后顺序,例如程序中:a=6;b=7;
编译器优化后可能变成 b=7;a=6;
,在这个例子中,编译器调整了语句的顺序,但是不影响程序的最终结果。不过有时候编译器及解释器的优化可能导致意想不到的 Bug。
我们看一下个经典的单例例子
// 单例:双重检测
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
操作应该是:
- 分配一块内存 M;
- 在内存 M 上初始化 Singleton 对象;
- 然后 M 的地址赋值给 instance 变量。
但是实际上优化后的执行路径2和3是相反的。
我们假设线程 A 先执行 getInstance()
方法,当执行完指令 2 时恰好发生了线程切换,切换到了线程 B 上;如果此时线程 B 也执行 getInstance()
方法,那么线程 B 在执行第一个判断时会发现 instance != null
,所以直接返回 instance
,而此时的 instance
是没有初始化过的,如果我们这个时候访问 instance
的成员变量就可能触发空指针异常。
另外,我当时在学习的时候,有一个问题非常的疑惑。编译器为什么要对指令重排序呢?重排序怎么提升性能?
指令优化只是编译器优化能力的一种。 在计算机大量的指令当中有着“二八定则”,指的是有着 20% 的指令在 80% 的时间里重复使用着,而 80% 的指令只有 20% 的时间在使用着。 那么为了提高计算机的工作效率,在指令的调用上,要想办法把那 20% 的指令尽可能的放在近的地方,而那剩下的指令可以放在稍微远一些的地方
-
程序中的某条指令一旦被执行,不久之后这条指令很可能再次被执行;
-
如果某条数据被访问,不久之后这条数据很可能再次被访问。
-
某块内存一旦被访问,不久之后这块内存附近的内存也很可能被访问。
比如第1行:a=8
第1000行:a=a*2;
这个时候,把他们放到一起执行,是不是就能更好的利用缓存了?
最后,其实缓存、线程、编译优化的目的和我们写并发程序的目的是相同的,都是提高程序性能。但是技术在解决一个问题的同时,必然会带来另外一个问题,所以在采用一项技术的同时,一定要清楚它带来的问题是什么,以及如何规避。