内存屏障及其在-JVM 内的应用(上)

做者:LeanCloud 后端高级工程师 郭瑞css

内容分享视频版本: 内存屏障及其在-JVM-内的应用html

MESI

MESI 的词条在这里:MESI protocol - Wikipedia,它是一种缓存一致性维护协议。MESI 表示 Cache Line 的四种状态,modified, exclusive, shared, invalidlinux

  • modified:CPU 拥有该 Cache Line 且将其作了修改,CPU 须要保证在重用该 Cache Line 存其它数据前,将修改的数据写入主存,或者将 Cache Line 转交给其它 CPU 全部;
  • exclusive:跟 modified 相似,也表示 CPU 拥有某个 Cache Line 但还将来得及对它作出修改。CPU 能够直接将里面数据丢弃或者转交给其它 CPU
  • shared:Cache Line 的数据是最新的,能够丢弃或转交给其它 CPU,但当前 CPU 不能对其进行修改。要修改的话须要转为 exclusive 状态后再进行;
  • invalid:Cache Line 内的数据为无效,也至关于没存数据。CPU 在找空 Cache Line 缓存数据的时候就是找 invalid 状态的 Cache Line;

有个超级棒的可视化工具,能看到 Cache 是怎么在这四个状态之间流转的:VivioJS MESI animation help。Address Bus 和 Data Bus 都是全部 CPU 都能监听到变化。好比 CPU 0 要读数据会把请求先发去 Address Bus,Memory 和其它 CPU 都会收到此次请求。Memory 经过 Data Bus 发数据时候也是全部 CPU 都会收到数据。我理解这就是能实现出来 Bus snooping的缘由。另外这个工具上可使用鼠标滚轮上下滚,看每一个时钟下数据流转过程。后端

A35D4118-F160-4CD5-85C3-AAF3A9B0F237.png

后续内容以及图片大多来自 Is Parallel Programming Hard, And, If So, What Can You Do About It? 这本书的附录 C。由于 MESI 协议自己很是复杂,各类状态流转很麻烦,因此这本书里对协议作了一些精简,用比较直观的方式来介绍这个协议,好处是让理解更容易。若是想知道协议的真实样貌须要去看上面提到的 WIKI。缓存

协议

  • Read: 读取一条物理内存地址上的数据;
  • Read Response: 包含 Read 命令请求的数据结果,能够是主存发来的,也能够是其它 CPU 发来的。好比被读的数据在别的 CPU 的 Cache Line 上处于 modified 状态,这个 CPU 就会响应 Read 命令
  • Invalidate:包含一个物理内存地址,用于告知其它全部 CPU 在本身的 Cache 中将这条地址对应的 Cache Line 清理;
  • Invalidate Acknowledge:收到 Invalidate,在清理完本身的 Cache 后,CPU 须要回应 Invalidate Acknowledge
  • Read Invalidate:至关于将 ReadInvalidate 合起来发送,一方面收到请求的 CPU 要考虑构造 Read Response 还要清理本身的 Cache,完成后回复 Invalidate Acknowledge,即要回复两次;
  • Writeback:包含要写的数据地址,和要写的数据,用于让对应数据写回主存或写到某个别的地方。

发起 Writeback 通常是由于某个 CPU 的 Cache 不够了,好比须要新 Load 数据进来,可是 Cache 已经满了。就须要找一个 Cache Line 丢弃掉,若是这个被丢弃的 Cache Line 处于 modified 状态,就须要触发一次 WriteBack,多是把数据写去主存,也可能写入同一个 CPU 的更高级缓存,还有可能直接写去别的 CPU。好比以前这个 CPU 读过这个数据,可能对这个数据有兴趣,为了保证数据还在缓存中,可能就触发一次 Writeback 把数据发到读过该数据的 CPU 上。安全

举例:并发

67CBFEBF-9125-48AF-BA0A-38B35B7E6458.png

左边是操做执行顺序,CPU 是执行操做的 CPU 编号。Operation 是执行的操做。RMW 表示读、修改、写。Memory 那里 V 表示内存数据是 Valid。ide

  1. 一开始全部缓存都是 Invalid;
  2. CPU 0 经过 Read 消息读 0 地址数据,0 地址所在 Cache Line 变成 Shared 状态;
  3. CPU 3 再执行 Read 读 0 地址,它的 0 地址所在 Cache Line 也变成 Shared;
  4. CPU 0 又从 Memory 读取 8 地址,替换了以前存 0 地址的 Cache Line。8 地址所在 Cache Line 也标记为 Shared
  5. CPU 2 由于要读取并修改 0 地址数据,因此发送 Read Invalidate 请求,首先 Load 0 地址数据到 Cache Line,再让当前 Cache 了 0 地址数据的 CPU 3 的 Cache 变成 Invalidate;
  6. CPU 2 修改了 0 地址数据,0 地址数据在 Cache Line 上进入 Modified 状态,而且 Memory 上数据也变成 Invalid 的;
  7. CPU 1 发送 Read Invalidate 请求,从 CPU 2 获取 0 地址的最新修改,并设置 CPU 2 上 Cache Line 为 Invalidate。CPU 1 在读取到 0 地址最新数据后对其进行修改,Cache Line 进入 Modified 状态。注意这里 CPU 2 没有 Writeback 0 地址数据到 Memory
  8. CPU 1 读取 8 地址数据,由于本身的 Cache Line 满了,因此 Writeback 修改后的 0 地址数据到 Memory,读 8 地址数据到 Cache Line 设置为 Shared 状态。此时 Memory 上 0 地址数据进入 Valid 状态

真实的 MESI 协议很是复杂,MESI 由于是缓存之间维护数据一致性的协议,因此它全部请求都分为两端,请求来自 CPU 仍是来自 Bus。请求来源不一样在不一样状态下也有不一样结果。下面图片来自 wiki MESI protocol - Wikipedia,只是贴一下大概瞧瞧就好。工具

Diagrama_MESI.GIF

Memory Barrier

Store Buffer

假设 CPU 0 要写数据到某个地址,有两种状况:oop

  1. CPU 0 已经读取了目标数据所在 Cache Line,处于 Shared 状态;
  2. CPU 0 的 Cache 中尚未目标数据所在 Cache Line;

第一种状况下,CPU 0 只要发送 Invalidate 给其它 CPU 便可。收到全部 CPU 的 Invalidate Ack 后,这块 Cache Line 能够转换为 Exclusive 状态。第二种状况下,CPU 0 须要发送 Read Invalidate 到全部 CPU,拥有最新目标数据的 CPU 会把最新数据发给 CPU 0,而且会标记本身的这块 Cache Line 为无效。

不管是 Invalidate 仍是 Read Invalidate,CPU 0 都得等其余全部 CPU 返回 Invalidate Ack 后才能安全操做数据,这个等待时间可能会很长。由于 CPU 0 这里只是想写数据到目标内存地址,它根本不关心目标数据在别的 CPU 上当前值是什么,因此这个等待是能够优化的,办法就是用 Store Buffer:

980CD4DD-2B6C-4349-83D8-28500A470308.png

每次写数据时一方面发送 Invalidate 去其它 CPU,另外一方面是将新写的数据内容放入 Store Buffer。等到全部 CPU 都回复 Invalidate Ack 后,再将对应 Cache Line 数据从 Store Buffer 移除,写入 CPU 实际 Cache Line。

除了避免等待 Invalidate Ack 外,Store Buffer 还能优化 Write Miss 的状况。好比即便只用一个 CPU,若是目标待写内存不在 Cache,正常来讲须要等待数据从 Memory 加载到 Cache 后 CPU 才能开始写,那有了 Store Buffer 的存在,若是待写内存如今不在 Cache 里能够不用等待数据从 Memory 加载,而是把新写数据放入 Store Buffer,接着去执行别的操做,等数据加载到 Cache 后再把 Store Buffer 内的新写数据写入 Cache。

另外对于 Invalidate 操做,有没有可能两个 CPU 并发的去 Invalidate 某个相同的 Cache Line?

这种冲突主要靠 Bus 解决,能够从前面 MESI 的可视化工具看到,全部操做都得先访问 Address Bus,访问时会锁住 Address Bus,因此一段时间内只有一个 CPU 会操做 Bus,会操做某个 Cache Line。可是两个 CPU 能够不断相互修改同一个内存数据,致使同一个 Cache Line 在两个 CPU 上来回切换。

Store Forwarding

前面图上画的 Store Buffer 结构还有问题,主要是读数据的时候还须要读 Store Buffer 里的数据,而不是写完 Store Buffer 就结束了。

好比如今有这个代码,a 一开始不在 CPU 0 内,在 CPU 1 内,值为 0。b 在 CPU 0 内:

a = 1;
b = a + 1;
assert(b == 2);

CPU 0 由于没缓存 a,写 a 为 1 的操做要放入 Store Buffer,以后须要发送 Read Invalidate 去 CPU 1。等 CPU 1 发来 a 的数据后 a 的值为 0,若是 CPU 0 在执行 a + 1 的时候不去读取 Store Buffer,则执行完 b 的值会是 1,而不是 2,致使 assert 出错。

因此更正常的结构是下图:

CA88D5E5-F7E8-474C-9D14-D1761DA38863.png

另一开始 CPU 0 虽然就是想写 a 的值 为 1,根本不关心它如今值是什么,但也不能直接发送 Invalidate 给其它 CPU。由于 a 所在 Cache Line 上可能不仅 a 在,可能还有别的数据在,若是直接发送 Invalidate 会致使 Cache Line 上不属于 a 的数据丢失。因此 Invalidate 只有在 Cache Line 处于 Shared 状态,准备向 Exclusive 转变时才会使用。

Write Barrier

// CPU 0 执行 foo(), 拥有 b 的 Cache Line
void foo(void) 
{ 
    a = 1; 
    b = 1; 
} 
// CPU 1 执行 bar(),拥有 a 的 Cache Line
void bar(void)
{
    while (b == 0) continue; 
    assert(a == 1);
}

对 CPU 0 来讲,一开始 Cache 内没有 a,因而发送 Read Invalidate 去获取 a 所在 Cache Line 的修改权。a 写入的新值存在 Store Buffer。以后 CPU 0 就能够当即写 b = 1 由于 b 的 Cache Line 就在 CPU 0 上,处于 Exclusive 状态。

对 CPU 1 来讲,它没有 b 的 Cache Line 因此须要先发送 Read 读 b 的值,若是此时 CPU 0 恰好写完了 b = 1,CPU 1 读到的 b 的值就是 1,就能跳出循环,此时若是还未收到 CPU 0 发来的 Read Invalidate,或者说收到了 CPU 0 的 Read Invalidate 可是只处理完 Read 部分给 CPU 0 发回去 a 的值即 Read Response 但还未处理完 Invalidate,也即 CPU 1 还拥有 a 的 Cache Line,CPU 0 仍是不能将 a 的写入从 Store Buffer 写到 CPU 0 的 Cache Line 上。这样 CPU 1 上 a 读到的值就是 0,从而触发 assert 失败。

上述问题缘由就是 Store Buffer 的存在,若是没有 Write Barrier,写入操做可能会乱序,致使后一个写入提早被其它 CPU 看到。

这里可能的一个疑问是,上述问题能出现意味着 CPU 1 在收到 Read Invalidate 后还未处理完就能发 Read 请求给 CPU 0 读 b 变量的 Cache Line,感受上彷佛不合理,由于彷佛 Cache 应该是收到一个请求处理一个请求才对。这里可能有理解的盲区,我猜想是由于 Read Invalidate 实际分为两个操做,一个 Read 一个 InvalidateRead 能够快速返回,可是 Invalidate 操做可能比较重,好比须要写回主存,那 Cache 可能有什么优化能容许等待执行完 Invalidate 返回 Invalidate Ack 前再收到 CPU 发来的轻量级的 Read 操做时能够把 Read 先丢出去,毕竟 CPU 读操做对 Cache 来讲只须要转发,Invalidate 则是真的要 Cache 去操做本身的标志之类的,作的事情更多。

上面问题解决办法就是 Write Barrier,其做用是将 Write Barrier 以前全部操做的 Cache Line 都打上标记,Barrier 以后的写入操做不能直接操做 Cache Line 而也要先写 Store Buffer 去,只是这种拥有 Cache Line 但由于 Barrier 关系也写入 Store Buffer 的 Cache Line 不用打特殊标记。等 Store Buffer 内带着标记的写入由于收到 Invalidate Ack 而能写 Cache Line 后,这些没有打标记的写入操做才能写入 Cache Line。

相同代码带着 Write Barrier:

// CPU 0 执行 foo(), 拥有 b 的 Cache Line
void foo(void) 
{ 
    a = 1; 
    smp_wmb();
    b = 1; 
} 
// CPU 1 执行 bar(),拥有 a 的 Cache Line
void bar(void)
{
    while (b == 0) continue; 
    assert(a == 1);
}

此时对 CPU 0 来讲,a 写入 Store Buffer 后带着特殊标记,b 的写入也得放入 Store Buffer。这样若是 CPU 1 还未返回 Invalidate Ack,CPU 0 对 b 的写入在 CPU 1 上就不可见。CPU 1 发来的 Read 读取 b 拿到的一直是 0。等 CPU 1 回复 Invalidate Ack 后,Ack 的是 a 所在 Cache Line,因而 CPU 0 将 Store Buffer 内 a = 1 的写入写到本身的 Cache Line,在从 Store Buffer 内找到全部排在 a 后面不带特殊标记的写入,即 b = 1 写入本身的 Cache Line。这样 CPU 1 再读 b 就会拿到新值 1,而此时 a 在 CPU 1 上由于回复过 Invalidate Ack,因此 a 会是 Invalidate 状态,从新读 a 后获得 a 值为 1。assert 成功。

Invalidate Queue

每一个 CPU 上 Store Buffer 都是有限的,当 Store Buffer 被写满以后,后续写入就必须等 Store Buffer 有位置后才能再写。就致使了性能问题。特别是 Write Barrier 的存在,一旦有 Write Barrier,后续全部写入都得放入 Store Buffer 会让 Store Buffer 排队写入数量大幅度增长。因此须要缩短写入请求在 Store Buffer 的排队时间。

以前提到 Store Buffer 存在缘由就是等待 Invalidate Ack 可能较长,那缩短 Store Buffer 排队时间办法就是尽快回复 Invalidate AckInvalidate 操做时间长来自两方面:

  1. 若是 Cache 特别繁忙,好比 CPU 有大量的在 Cache 上的读取、写入操做,可能致使 Cache 错过 Invalidate 消息,致使 Invalidate 延迟 (我认为是收到总线上信号后若是来不及处理能够丢掉,等信号发送方待会重试)
  2. 可能短期到来大量的 Invalidate,致使 Cache 来不及处理这么多 Invalidate 请求。每一个还得回复 Invalidate Ack,也会占用总线通讯时间

因而解决办法就是为每一个 CPU 再增长一个 Invalidate Queue。收到 Invalidate 请求后将请求放入队列,并当即回复 Ack

E5B30CFB-09FE-4873-81CF-D7553F0596C4.png

这么作致使的问题也是显而易见的。一个被 Invalidate 的 Cache Line 原本应该处于 Invalidate 状态,CPU 不应读、写里面数据的,但由于 Invalidate 请求被放入队列,CPU 还认为本身能够读写这个 Cache Line 而在操做老旧数据。从上图能看到 CPU 和 Invalidate Queue 在 Cache 两端,因此跟 Store Buffer 不一样,CPU 不能去 Invalidate Queue 里查一个 Cache Line 是否被 Invalidate,这也是为何 CPU 会读到无效数据的缘由。

另外一方面,Invalidate Queue 的存在致使若是要 Invalidate 一个 Cache Line,得先把 CPU 本身的 Invalidate Queue 清理干净,或者至少有办法让 Cache 确认一个 Cache Line 在本身这里状态是非 Invalidate 的。

Read Barrier

由于 Invalidate Queue 的存在,CPU 可能读到旧值,场景以下:

// CPU 0 执行 foo(), a 处于 Shared,b 处于 Exclusive
void foo(void) 
{ 
    a = 1; 
    smp_wmb();
    b = 1; 
} 
// CPU 1 执行 bar(),a 处于 Shared 状态
void bar(void)
{
    while (b == 0) continue; 
    assert(a == 1);
}

CPU 0 将 a = 1写入 Store Buffer,发送 Invalidate (不是 Read Invalidate,由于 a 是 Shared 状态) 给 CPU 1。CPU 1 将 Invalidate 请求放入队列后当即返回了,因此 CPU 0 很快能将 1 写入 a、b 所在 Cache Line。CPU 1 再去读 b 的时候拿到 b 的新值 0,读 a 的时候认为 a 处于 Shared 状态因而直接读 a,拿到 a 的旧值好比 0,致使 assert 失败。最后,即便程序运行失败了,CPU 1 还须要继续处理 Invalidate Queue,把 a 的 Cache Line 设置为无效。

解决办法是加 Read Barrier。Read Barrier 起做用不是说 CPU 看到 Read Barrier 后就当即去处理 Invalidate Queue,把它处理完了再接着执行剩下东西,而只是标记 Invalidate Queue 上的 Cache Line,以后继续执行别的指令,直到看到下一个 Load 操做要从 Cache Line 里读数据了,CPU 才会等待 Invalidate Queue 内全部刚才被标记的 Cache Line 都处理完才继续执行下一个 Load。好比标记完 Cache Line 后,又有新的 Invalidate 请求进来,由于这些请求没有标记,因此下一次 Load 操做是不会等他们的。

// CPU 0 执行 foo(), a 处于 Shared,b 处于 Exclusive
void foo(void) 
{ 
    a = 1; 
    smp_wmb();
    b = 1; 
} 
// CPU 1 执行 bar(),a 处于 Shared 状态
void bar(void)
{
    while (b == 0) continue; 
    smp_rmb();
    assert(a == 1);
}

有了 Read Barrier 后,CPU 1 读到 b 为 0后,标记全部 Invalidate Queue 上的 Cache Line 继续运行。下一个操做是读 a 当前值,因而开始等全部被标记的 Cache Line 真的被 Invalidate 掉,此时再读 a 发现 a 是 Invalidate 状态,因而发送 Read 到 CPU 0,拿到 a 所在 Cache Line 最新值,assert 成功。

除了 Read Barrier 和 Write Barrier 外还有合二为一的 Barrier。做用是让后续写操做所有先去 Store Buffer 排队,让后续读操做都得先等 Invalidate Queue 处理完。

其它参考

相关文章
相关标签/搜索