从计算机硬件组成出发,深入理解 CPU 缓存以及缓存一致性协议。
CPU 基本结构
CPU
- Registers:寄存器,当从内存读取到指令时,先到达 Registers 处。
- Arithmetic Logic Unit (ALU) :逻辑运算单元。
- Program Counter (PC) :程序计算器,用于记录当前执行到的指令编号。
- Cache:分别包括 L1 缓存、L2 缓存、L3 缓存。对于一颗多核 CPU 来说,每个核有自己的 L1 缓存和 L2 缓存,而整一个核共享一个 L3 缓存。Registers 寄存器严格意义上可以称作 L0 缓存。
- Control Unit (CU) :控制单元。
- Memory Management Unit (MMU) :内存管理单元。
所谓的 CPU 在多个线程之间快速调度,其实就是更新 Registers 和 PC 上的内容。称为 Context Switch 上下文切换。
当 CPU 正在执行某个线程的时候,Registers 装的是当前线程中参与运算的值,PC 装的是当前线程的执行指令的内容;而当 CPU 进行线程切换时,需要将当前 CPU 上的 Registers 和 PC 上的内容进行备份,再将被调度的线程原本的 Registers 和 PC 内容在 CPU 上进行恢复。
这也就是为什么别人说多线程切换成本高昂,因为切换时存在保存和恢复线程的 Registers 和 PC 中内容的过程。而协程则不需要保存线程上下文( Registers 和 PC 中内容)的操作。
CPU 各级缓存和内存速度对比
位置 | 速度 |
---|---|
CPU Registers (L0 Cache) | < 1ns |
L1 Cache | ≈ 1ns |
L2 Cache | ≈ 3ns |
L3 Cache | ≈ 15ns |
Memory | ≈ 80ns |
CPU 中 各级 Cache 的读写速度是 Memory 速度的几倍到几十倍不等,而 CPU Registers 的读写速度则是 Memory 的上百倍。高效但也高成本,所以现代 CPU 的各级缓存一般只有 1~3 M ,而 Memory 基本都是 4~16 G 。
可以通过命令 dmesg |grep cache
查看 CPU Cache :
1 | [root@izwz9dnvgmsob4dzui5wgpz ~]# dmesg |grep cache |
通过命令 free -m
查看 Memory :
1 | [root@izwz9dnvgmsob4dzui5wgpz ~]# free -m |
Cache Line
根据程序的局部性原理(当使用到某个区域的数据时,有较大的可能性相邻区域的数据也会被马上访问),为了提高读取效率,无论是操作系统层面的还是数据库软件层面的,都是采取按块读取策略。
在操作系统层面上,无论是从硬盘往内存读,还是内存往 CPU 读都是严格按块进行读取。在内存往 CPU 读的过程中,这个块又称为缓存行 Cache Line。大小为 64 个字节。
通过读取 coherency_line_size 文件可以查看缓存行大小:
1 | [root@izwz9dnvgmsob4dzui5wgpz ~]# more /sys/devices/system/cpu/cpu1/cache/index0/coherency_line_size |
缓存一致性协议
- MESI
MESI 分别代表了 Cache Line 的四种状态。
Modifiled:修改状态。该缓存行只被缓存在该 CPU 的缓存中,并且是被修改过的(
dirty
),即与主存中的数据不一致。该缓存行中的内存需要在未来的某个时间点(允许其他 CPU 读取请主存中相应内存之前)写回(write back
)主存。Exclusive:独享状态。该缓存行只被缓存在当前 CPU 的缓存中,它是未被修改过的(
clean
),与主存中数据一致。该状态可以在任何时刻当有其他 CPU 读取该内存时变成共享状态(shared
)。Shared:共享状态。该状态意味着该缓存行可能被多个 CPU 缓存,并且各个缓存中的数据与主存数据一致(
clean
),当有一个 CPU 修改该缓存行中,其他 CPU 中该缓存行可以被作废(变成Invalid
状态)。Invalid:失效状态。该缓存是无效的(可能有其他 CPU 修改了该缓存行)。
当某个缓存行上的内容被修改过了,该缓存行在被修改的那个核上面的状态为 Modifiled ,在其他核心上的状态为 Invalid 。
总线锁
当数据很大,跨越多个缓存行,或者该数据根本无法缓存,这时候使用缓存一致性协议已经不能满足了。应该使用总线锁。
一旦总线锁上锁,同一时刻只有一个 CPU 能访问内存,由此保证数据的一致性。
有趣的循环实验
1 | public class TestContext { |
为什么数据成员数量不同,时间会相差这么多?答案是前面提到的 Cache Line 缓存行发生了“伪共享”。我们知道缓存行的大小默认为 64 字节。
当设定数组成员为 2 个的时候,整个数组的大小为 8 字节(4 字节 * 2),整个数组有极大的可能性位于同一个缓存行里(假设整个数组就是在一个缓存行里)。修改数组最前一位和最后一位,实际上在修改同一个缓存行,由于数组还使用了 volatile
进行修饰(保存了线程可见性),所以每次在 A 线程修改完,B 线程必须要重新到内存读取,更新缓存行,才能进行修改。
当设定数组成员为 17 个的时候,整个数组大小为 68 字节(4 字节 * 17),数组的第一位和最后一位必然不在缓存行里(假设分别位于缓存行 1 和缓存行 2 中)。修改数组最前一位和最后一位,虽然数组使用了 volatile
修饰,B 线程要对在 A 线程更新的值进行重新读取(更新缓存行 1 ),但由于 B 线程修改的是数组最后一位(位于缓存行 2 中),所以不影响 B 线程在更新缓存行 1 的同时修改缓存行 2。
伪共享
伪共享是指多个变量被放入了同一个缓存行中,并且有多个线程同时去写入缓存行中不同的变量产生的现象 。
缓存行对齐
要避免伪共享,可以使用缓存行对齐的解决方案。确保被多线程修改的变量处于一个独立的缓存行中。
在 JDK 1.8 以前,主要通过在变量前后手动使用 long 变量做缓存行填充:
1 | public class Main { |
由于不同的 CPU 架构上缓存行的大小可能会不一样(64字节大小的缓存行通常是指在英特尔 CPU 下),填充多少个 long 变量有时并不好决定,为了保证在不同的 CPU 上都达到缓存行对齐的效果,也是为了更好的代码阅读性:
1 | import sun.misc.Contended; |
使用 @Contended
注解,并开启 JVM -XX:-RestrictContended
参数(JDK 1.8)。可以保证某个成员变量放在一个独立缓存行里面。
评论