内存屏障是一个很晦涩的概念,今天为了让大家彻底了解在高级并发里面的这个高级主题,我们从系统层面向大家讲解内存屏障的原理、种类、应用, 下面的内容可能牵涉的东西比较多并且比较长,但是如果你耐心的看完,并且完全的吸纳,那么你就会真正的了解内存屏障!
1 速度不对等
Cpu的速度比cpu之间的互联性能及cpu试图要访问的内存性能,都要快上几个数量级
上面展示的图显示现代处理器基本都是多核,并且每个cpu都有自己独立的cache,不同cpu共享主内存,然后不同cpu通过总线互联,cpu -> cache -> memory 访问速度成大数量级递减,cpu最快,cache慢一点,memory更慢。
2 MESI协议
cpu从内存中加载数据到自己的cache,当不同的cpu都加载了同样的内存数据的时候,并且对数据进行操作的时候,需要维护数据在不同的cache 中的一致性视图就需要MESI协议,cache里面的缓存行有四种状态分别是Modified,Exclusive,Shared,Invalid。协议在每一个缓存行中维护 一个两位的状态“tag”, 这个“tag”附着在缓存行的物理地址或者数据后 ,标识着缓存行的状态
•Modified 修改的
•Exclusive 独占的
•Shared 共享的
•Invalid 无效的
Modified: 处于“modified”状态的缓存行是由于相应的 CPU 最近进行了内存存储。并 且相应的内存确保没有在其他 CPU 的缓存中出现。因此,“modified”状态的缓 存行可以被认为被 CPU 所“owned”。由于缓存保存了最新的数据,因此缓存最 终有责任将数据写回到内存,并且也应当为其他缓存提供数据,必须在当前缓存 缓存其他数据之前完成这些事情。
Exclusive: 状态非常类似于“modified”状态,唯一的例外是缓存行还没 有被相应的 CPU 修改,这表示缓存行中的数据及内存中的数据都是最新的。但 是,由于 CPU 能够在任何时刻将数据保存到该行,而不考虑其他 CPU,处于 exclusive 状态也可以认为被相应的 CPU 所“owned”。也就是说,由于内存 中的值是最新的,该行可以直接丢弃而不用回写到内存,也可以为其他缓存提供 数据。
Shared: 处于“shared”状态的缓存行可能被复制到至少一个其他 CPU 缓存中,这样 在没有得到其他 CPU 的许可时,不能向缓存行存储数据。由于“exclusive”状 态下,内存中的值是最新的,因此可以不用向内存回写值而直接丢弃缓存中的值, 或者向其他 CPU 提供值。
Invalid: 处于“invalid”状态的行是空的,换句话说,它没有保存任何有效数据。当 新数据进入缓存时,它替换一个处于“invalid”状态的缓存行。这个方法是比较 好的,因为替换其他状态的缓存行将引起大量的 cache miss。
如果上面的文字你都认真看完了,可能有点绕,不过没关系,我们只需要了解这四种状态仅仅是标识出当前在cache里面的缓存行的数据是处于一个什么样的状态,并且下面会简单的介绍通过发送MESI消息来改变这种缓存行的状态
缓存一致性消息:
由于所有 CPUs 必须维护缓存行中的数据一致性视图,因此缓存一致性协议 提供消息以调整系统缓存行的运行。
MESI 协议消息 :
Read:“read”消息包含缓存行需要读的物理地址。
Read Response:“read response”消息包含较早前的“read”消息的数据。 这个“read response”消息可能由内存或者其他缓存提供。例如,如果 一个缓存请求一个处于“modified”状态的数据,则缓存必须提供“read response”消息。
Invalidate“invalidate”消息包含要使无效的缓存行的物理地址。其他的 缓存必须从它们的缓存中移除相应的数据并且响应此消息。
Invalidate Acknowledge:一个接收到“invalidate”消息的 CPU 必须在移 除指定数据后响应一个“invalidate acknowledge”消息。
Read Invalidate:“read invalidate”消息包含要缓存行读取的物理地址。 同时指示其他缓存移除数据。因此,它包含一个“read”和一个 “invalidate”。“read invalidate”也需要“read response”以及“invalidate acknowledge”消息集。
Writeback:“writeback”消息包含要回写到内存的地址和数据。(并且也 许会“snooped”其他 CPUs 的缓存)。这个消息允许缓存在必要时换出 “modified”状态的数据以腾出空间。
ok,我们不用太纠结这些消息,毕竟它看上去实在太枯燥了,我们只需要了解它大概的用途,说了这么多,下面内存屏障要出马了
未完!
在上面的理论储备下,第一个存在的问题 存储导致的停顿
在这个动作中,为了解决这种停顿的性能损耗我们cpu的设计引入了一种机制叫做 存储缓冲
避免这种不必要的写延迟的方法之一,就是在每个 CPU 和它的缓存之间, 增加“store buffers”。通过增加这些存储缓冲区,CPU0 可能简单的将 要保存的数据放到存储缓冲区中,并且继续执行。当缓存行最后从 cpu1 转到 CPU0 时,数据将从存储缓冲区转到缓存行中。
在引入了存储缓冲区之后又会带来新的问题,我们看以下代码验证失败的情况
//变量a,b初始化为0,
void foo(void)
{
a = 1;
b = 1;
}
void bar(void)
{
while (b == 0) continue;
assert(a == 1);
}
验证失败
CPU 0 执行 a = 1。缓存行不在 CPU0 的缓存中,因此 CPU0 将“a”的新值 放到存储缓冲区,并发送一个“read invalidate”消息。
CPU 1 执行 while (b == 0) continue,但是包含“b”的缓存行不在缓存中, 它发送一个“read”消息。
CPU 0 执行 b = 1,它已经在缓存行中有“b”的值了 (换句话说,缓存行已 经处于“modified”或者“exclusive”状态),因此它存储新的“b”值在它的缓 存行中。
CPU 0 接收到“read”消息,并且发送缓存行中的新的“b”的值 1,同时 将缓存行设置为“shared”状态。
CPU 1 接收到包含“b”值的缓存行,并将其值写到它的缓存行中。
CPU 1 现在完成执行 while (b == 0) continue, 由于它发现“b”的值是 1, 它开始处理下一条语句。
CPU 1 执行 assert(a == 1),并且, 由于 CPU 1 工作在旧的“a”的值,因此 验证失败。
CPU 1 接收到“read invalidate”消息, 并且发送包含“a”的缓存行到 CPU0, 同时使它的缓存行变成无效。但是已经太迟了。
CPU 0 接收到包含“a”的缓存行,将且将存储缓冲区的数据保存到缓存行 中,这使得 CPU1 验证失败。
结果是验证失败了,但是这和我们看到的,和预想的逻辑是不一样的,那怎么解决这个问题 ,引入内存屏障, 内存屏障千呼万唤始出来,只有前面这些铺垫,我们才能真正的理解内存屏障是个啥玩意,它是什么样的条件下产生的,以及他解决了什么问题,我们再看下面这段代码
void foo(void) {
a = 1;
smp_mb();
b = 1;
}
void bar(void)
{
while (b == 0) continue;
assert(a == 1);
}
准确的讲,这里需要插入一个写内存屏障,写内存屏障施加的作用就是
内存屏障 smp_mb()将导致 CPU 在保存后续的存储操作到缓存行前,刷新它 的存储缓冲区。CPU 可能简单的停下来,直到存储缓冲区变成空,也可能是简 单的使用存储缓冲区保存后续的存储操作,直到前面所有的存储缓冲区已经被保 存到缓存行中。
然后我们看插入一个写内存屏障之后,cpu的流水线是这样的
CPU 0 执行 a = 1。缓存行不在缓存中,因此 CPU 0 将“a”的新值放到存 储缓冲区,并发送一个“read invalidate”消息.
CPU 1 执行 while (b == 0) continue,但是包含“b”的缓存行不在缓存中, 因此它发送一个“read”消息.
CPU 0 执行 smp_mb(),,并标记当前所有存储缓冲区的条目。 (也就是说 a = 1).
CPU 0 执行 b = 1。它的缓存行已经存在了。 (也就是说, 缓存行已经处于 “modified”或者“exclusive”状态),但是在存储缓冲区中存在一个标记条目。 因此,它不将新值存放到缓存行,而是存放到存储缓冲区中。 (但是“b”不是 一个标记条目).
CPU 0 接收到“read”消息,同时发送包含“b”值的缓存行给 CPU1。它 也标记本地缓存行为“shared”。
CPU 1 读取到包含“b”的缓存行,并将它复制到本地缓存中。
CPU 1 现在可以装载“b”的值了,但是发现它的值仍然为“0”,因此它重 复该语句。“b”的新值被安全的隐藏在 CPU0 的存储缓冲区中。
CPU 1 接收到“read invalidate”消息,并且发送包含“a”的缓存行给 CPU0, 并且使它的缓存行无效。
CPU 0 接收到包含“a”的缓存行,并且使用存储缓冲区的值替换缓存行为 “modified”状态。
由于被存储的“a”是存储缓冲区中唯一被 smp_mb()标记的条目,因此 CPU0 也能够存储“b”的新值到缓存行中-除非包含“b”的缓存行处于“shared”状态。
CPU 0 发送一个“invalidate”消息给 CPU 1。
CPU 1 接收到“invalidate”消息,刷新包含“b”的缓存行,并且发送一个 “acknowledgement”消息给 CPU 0.
CPU 1 执行 while (b == 0) continue,但是包含“b”的缓存行不在缓存中, 因此它发送一个“read”消息给 CPU 0。
CPU 0 接收到“acknowledgement”消息,将包含“b”的缓存行设置成 “exclusive”状态。 CPU 0 现在存储新的“b”值到缓存行。
CPU 0 接收到“read”消息,同时发送包含新的“b”值的缓存行给 CPU 1。 它也标记该缓存为“shared”。
CPU 1 接收到包含“b”的缓存行,并将它复制到本地缓存中。
CPU 1 现在能够装载“b”的值了,由于它发现“b”的值为 1,它退出循环 并执行下一条语句。
CPU 1 执行 assert(a == 1),但是包含“b”的缓存行不在它的缓存中。一旦 它从 CPU0 获得这个缓存行,它将使用最新的“a”的值,验证语句将通过。
读到这里其实已经很复杂了,上面讲到的写内存屏障这个机制,其实还有一个读内存屏障,读内存屏障标记的是cpu互联模块里面的使无效队列,我们先不列出和读内存屏障相关的cpu流水线,它的原理和写内存屏障类似,下面列出简要的图
在上面讲到的代码中完整的例子应该是这样的
#如果我们修改 foo 和 bar ,以使用读和写内存屏障,将会是如下所示:
void foo(void)
{
a = 1;
smp_wmb();
b = 1;
} void bar(void) {
while (b == 0) continue;
smp_rmb();
assert(a == 1);
}
内存屏障总结
内存屏障用来标记存储缓冲区和无效队列
简单的说,一个“读内存屏障” 仅仅标记它的无效队列,一个“写内存屏障”仅仅标记它的存储缓冲区,完整的 内存屏障同时标记无效队列及存储缓存缓冲区。
这样的效果是:读内存屏障仅仅保证装载顺序,因此所有在读内存屏障之前 的装载将在所有之后的装载前完成。类似的,写内存屏障仅仅保证写之间的顺序。 完整的内存屏障同时保证写和读之间的顺序。
内存屏障有四个基本变种:
写 (或存储) 内存屏障,
数据依赖屏障,
读内存屏障,
通用内存屏障。
cpu乱序执行,但是它必须有它基本的规范,就比如我们看到的代码的逻辑,它执行的时候,逻辑也应该是像我们看到的那样,乱序cpu技术规范
1.每一个 CPU 总是按照编程顺序来感知内存访问。
2.仅仅在操作不同地址时,CPUs 才对特定的存储操作进行重新排序。
3.一个特定 CPU 在内存屏障之前的所有装载操作 (smp_rmb()) 将被所有随后的读内存屏障后面的操作之前被所有 CPU 所感知
4.所有在写内存屏障之前的写操作 (smp_wmb()) 都将比随后的写操作先 感知。
5.所有在内存屏障之前的内存访问 (装载和存储) (smp_mb())都将比随后的内存访问先感知。
好了,看到这里,如果你都看明白了,那么恭喜你,内存屏障你大概了解了,内存屏障它的产生背景,以及为了解决什么问题都讲到了,其实整体的逻辑就是为了解决一个问题,采用了一种机制,这个机制又带来新的问题,然后又采用一种机制去解决,如此往复。