缓存一致性

从计算机硬件组成出发,深入理解 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
2
3
4
5
6
7
8
9
10
11
12
13
[root@izwz9dnvgmsob4dzui5wgpz ~]# dmesg |grep cache
[1432577.282881] 27131 total pagecache pages
[1432577.282883] 0 pages in swap cache
[1432577.282884] Swap cache stats: add 0, delete 0, find 0/0
[1439003.279992] 29408 total pagecache pages
[1439003.279994] 0 pages in swap cache
[1439003.279996] Swap cache stats: add 0, delete 0, find 0/0
[1439025.383145] [<ffffffff9cd94037>] __page_cache_alloc+0x97/0xb0
[1439025.383270] 29162 total pagecache pages
[1439025.383272] 0 pages in swap cache
[1439025.383273] Swap cache stats: add 0, delete 0, find 0/0
[1439036.787791] [<ffffffff9cd94037>] __page_cache_alloc+0x97/0xb0
...

通过命令 free -m 查看 Memory :

1
2
3
4
[root@izwz9dnvgmsob4dzui5wgpz ~]# free -m
total used free shared buff/cache available
Mem: 15885 6099 5546 21 4240 9353
Swap: 0 0 0


Cache Line

根据程序的局部性原理(当使用到某个区域的数据时,有较大的可能性相邻区域的数据也会被马上访问),为了提高读取效率,无论是操作系统层面的还是数据库软件层面的,都是采取按块读取策略。

在操作系统层面上,无论是从硬盘往内存读,还是内存往 CPU 读都是严格按块进行读取。在内存往 CPU 读的过程中,这个块又称为缓存行 Cache Line。大小为 64 个字节。

通过读取 coherency_line_size 文件可以查看缓存行大小:

1
2
[root@izwz9dnvgmsob4dzui5wgpz ~]# more /sys/devices/system/cpu/cpu1/cache/index0/coherency_line_size
64

缓存一致性协议

  • 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
public class TestContext {
// private static volatile int[] ints = new int[17]; // 打印约 2s
private static volatile int[] ints = new int[2]; // 打印约 4s

private static boolean flagA = false;

private static boolean flagB = false;

public static void main(String[] args) throws InterruptedException {

int count = 8_0000_0000;

Thread threadA = new Thread(new Runnable() {
@Override
public void run() {
for (int i = 0; i < count; i++) {
ints[0] = 1; // 修改数组的第一位
}
flagA = true;
}
});
threadA.start();

Thread threadB = new Thread(new Runnable() {
@Override
public void run() {
for (int i = 0; i < count; i++) {
// ints[16] = 1; // 修改数组的最后一位
ints[1] = 1; // 修改数组的最后一位
}
flagB = true;
}
});
threadB.start();

long start = System.currentTimeMillis();
while (!flagA || !flagB) {
Thread.sleep(30);
}
System.out.println(System.currentTimeMillis() - start);
}
}

为什么数据成员数量不同,时间会相差这么多?答案是前面提到的 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
2
3
4
5
public class Main {
public long p1, p2, p3, p4, p5, p6, p7; // cache line padding
private volatile long aLong;
public long p8, p9, p10, p11, p12, p13, p14; // cache line padding
}

由于不同的 CPU 架构上缓存行的大小可能会不一样(64字节大小的缓存行通常是指在英特尔 CPU 下),填充多少个 long 变量有时并不好决定,为了保证在不同的 CPU 上都达到缓存行对齐的效果,也是为了更好的代码阅读性:

1
2
3
4
5
6
import sun.misc.Contended;

public class Main {
@Contended
private volatile long aLong;
}

使用 @Contended 注解,并开启 JVM -XX:-RestrictContended 参数(JDK 1.8)。可以保证某个成员变量放在一个独立缓存行里面。


深入理解 MySQL 排序 深入理解 ReentrantLock

评论

Your browser is out-of-date!

Update your browser to view this website correctly. Update my browser now

×