关于 MESI 协议的一些疑问

843 阅读11分钟

看了几篇文章产生一些疑问,遂有此文章。

  1. 后端技术小牛说:26张图带你彻底搞懂volatile关键字
  2. 极客:39 | MESI协议:如何让多核CPU的高速缓存保持一致?
  3. 聊聊缓存一致性协议

我的思考:

首先,要解决缓存一致性问题,首先要解决的是多个 CPU 核心之间的数据传播问题,通过总线嗅探来解决。这句话点睛之句,后面所说的 RFO 及写失效都使用到了他。

文中说了

那么第一点的写传播的实现就是通过写失效协议。

这里写传播包含两个协议:

  • 写失效
  • 写广播/写更新

第二点的事务串行化,就是写数据之前需要先获取到锁才能写

这让我想到了 CSMA/CD 协议,在消息发送前检测信道是为了保证信道空闲,在发送中检测信道是为了能否检测到碰撞的发生。

MESI 协议作为缓存一致性协议的一种实现也是这样。

首先,CPU 要更新当前缓存行的数据,需要先获取缓存锁,就要先无效化其他 CPU 的 cache,然后当前 CPU 变为独占该缓存行以获取缓存锁了

共享状态下的缓存行必须监听其他缓存使该缓存行无效或者独占该缓存行的请求,并将该缓存行置为无效状态。如果高并发情况下,存在冲突,总线会采用相应的裁决机制进行裁决决定谁获得缓存锁。这就是对应到 CSMA/CD 中的发送前检测信道。

其次,CPU 更新完后,还要继续无效化其他 CPU 的 cache,这一点的实现就是写失效协议了。如果高并发情况下,存在冲突,总线会采用相应的裁决机制进行裁决,将其中一个置为M状态,另一个置为I状态。对应到 CSMA/CD 中的边发送边检测了。

这两点共同保证了缓存一致性协议的实现,同时,他俩也是依赖在总线上传播 M、E、S、I 消息,其他 CPU 通过总线嗅探的方式获取消息进而进行后续操作。

从第一篇和第三篇文章中了解到,写失效协议其实就是放在一开始要修改数据,获取当前对应 cache block 的所有权的时候使用的,确实,没必要发送两次写失效协议,那极客那篇文章就说错了吗,写入 cache 之后,干嘛还要去广播一个“失效”请求呢。

第二篇文章这样说的

  • 修改数据之前:

    RFO 的目的是要先获得要修改的 cache block 的所有权,那就要先发出无效化指令来无效化其他核对应的这个 cache line,其他核再发出无效化确认。

  • 修改数据之后:

    写失效协议要去广播一个“失效”请求告诉所有其他的 CPU 核心。其他的 CPU 核心,只是去判断自己是否也有一个“失效”版本的 Cache Block,然后把这个也标记成失效的就好了。

疑问 1: 修改数据之前已经无效化其他核的 cache 了,当前 CPU 已经获得所有权了,为什么修改数据之后还要再次无效化其他核呢?岂不是多此一举了。

当前 CPU 的这个 cache 状态是 M/E 都不需要发出无效化指令,说明当前 CPU 已经拥有了相应数据的所有权,直接修改就完事了;当前 CPU cache 状态为 S 才需要无效化其他核对应的 cache 并接收无效化确认指令。

答:明白了,MESI 整体应用的思想就是写失效协议,所以 cache 在 S 状态下获得所有权的时候就把写失效协议思想的内容实现了,无效化其他核中的 cache。极客那篇文章给我的理解带来了偏差,我错误的理解为写失效和获取所有权是两件事,原来是一件事,合并到一起了,事务串行化的过程即需要获得所有权的过程已经将写失效协议的内容实现出来了,无效化其他 cache。高并发情况下会有总线裁决谁获得所有权。

就是 RFO 的时候发出 Invalidate 消息,其他 cpu 通过总线嗅探方式得到这个消息,进而发出 Invalidate Acknowledge

疑问 2: 难道写失效协议是应用在 CPU 获取 cache 所有权时发出的无效化指令吗?但和极客文章中描述的又有差异,写失效就是使用在修改数据之后发出的,而获取 cache 所有权却是在修改数据之前发生的。而第一篇文章中说 “为了保证缓存一致性,每个核心要写新数据前,需要确保其他核心已经置同一变量数据的缓存行状态位为Invalid后,再把新数据写到自己的缓存行,并之后写到内存中。”,这句话像是说明写失效协议就是用在获取所有权时候使用的,而且第一篇和第三篇的文章中也没有描述第二篇文章中所说的修改数据之后会不会无效化其他 CPU 核心的 cache 的流程。

答:理解正确

疑问 3: 《Java 并发编程的艺术》P9 上说

“在多处理器下,为了保证各个处理器的缓存是一致的,就会实现缓存一致性协议,每个处理器通过嗅探在总线上传播的数据来检查自己缓存的值是不是过期了,当处理器发现自己缓存行对应的内存地址被修改,就会将当前处理器的缓存行设置成无效状态,当处理器对这个数据进行修改操作的时候,会重新从系统内存中把数据读到处理器缓存里。”

这里又说明了修改数据之后,回写到主存,处理器嗅探到了总线上传播的数据,就会无效化其 cache。这里的无效化又是否与写失效协议相关(感觉是一个东西)?为什么这里又来一次无效化呢?按我的理解,获得 cache 所有权的时候一次无效化就足够了的。不是很明白。

答:理解正确,就是写失效协议整体思想的实现,通过总线嗅探方式实现。

疑问 4: 我们再进一步分析下,高并发情况下,都发出 RFO,总线裁决后,只能有一个 CPU 获得 cache 的所有权进而进行修改操作,那这样的话,怎么会出现多个 CPU 同时修改缓存行的问题的呢?你要先获得所有权才行啊,难道仅仅是修改 cache 这个动作的时候要获得所有权?获得所有权之前可以进行 CPU 的计算?这样倒是可以解释了。还有如上所说的“为了保证缓存一致性,每个核心要写新数据前,需要确保其他核心已经置同一变量数据的缓存行状态位为Invalid后,再把新数据写到自己的缓存行,并之后写到内存中。”,写新数据之前,这个写的动作是 assain 还是那从 read->load->use->assain->store->write 的流程呢?不对,这里想错误,这是屏蔽了操作系统和硬件抽象出来的 JMM 模型,这里是硬件层面的,与 assain 什么的无关。

答:是原子性命令才行,且要修改当前 cache line 才会无效化其他 cache 从而获得所有权。读操作就不会无效化其他 cacheline, i++ 操作不具有原子性,它分了 load、Increment、store 三个步骤,如果 cache 没有此值,就会发出 Read 消息,最后 store 才会修改 cacheline,从而发出 Invalidate 消息,在 store 之前的操作就可以多 CPU 同时计算 i++,但并不会出现多个 cpu 同时修改 cache line 的情况。cas 不一样,在一开始执行 cas 操作时就发出了 Invalidate 消息,cas 操作本身就是个原子性修改 cache 的指令。以下是具体过程的分析。

  • 读操作

    假设现在有 CPU1 和 CPU2,需要执行 j= i+1 计算操作,i 初始为 0。CPU1 cacheline 中没有 i 值,CPU2 的 cacheline 中有 i=0 的值,为 E 状态。

  1. CPU1 执行 j=i+1 操作,没有 i 值,需要向总线发出 Read 消息,CPU2 会构造 Read Response 消息将值传到总线上,同时变为 S 状态,CPU1 收到消息后放入缓存行也更新为 S 状态。
  • 写操作

    假设现在有 CPU1 和 CPU2,需要执行 i=1 赋值操作,i 初始为 0。CPU1 cacheline 中没有 i 值,CPU2 的 cache 中有 i=0 的值,为 E 状态。

  1. CPU1 执行 i=1 操作,没有 i 值,要更新 cache 中的值,先获得所有权,向总线发出 Read Invalidate 消息。

  2. CPU2 收到消息,将 i 的 cache 置为 I 状态,同时发送 Read Response 消息及 Invalidate Acknowledge 消息。

  3. CPU1 收到消息,获得 i 的 cache line 的所有权,变为 E 状态,开始更新 cache,变为 M 状态。

    假设现在有 CPU1 和 CPU2,需要执行 i++ 的 cas 指令操作,i 初始为 0。CPU1 cacheline 中和 CPU2 cacheline 中皆有 i=0 的值,皆为 S 状态。

  4. CPU1 执行 cas 命令,试图修改这个 cache line,则向总线发出 Invalidate 消息。

  5. CPU2 收到消息,将 i 的 cache 更改为 I 状态,回复 Invalidate Acknowledge 消息。

  6. CPU1 收到消息,获得所有权,变为 E 状态,开始更新 cache,变为 M 状态。

    假设现在有 CPU1 和 CPU2,需要执行 i++ 操作,i 初始为 0。CPU1 cache 中的 i 为 I 状态,CPU2 的 cache 中有 i=0 的值,为 E 状态。

  7. CPU1 执行 i++ 操作,此时是发出 Read 消息还是 Read Invalidate 消息呢?我觉得这个应该跟操作命令的原子性有关,向上面说的 i=1 赋值操作,原子性命令,向总线发出 Read Invalidate 消息;向上面说的 cas 操作,会发出 Invalidate 消息;而 i++实际为 load、Increment、store 三个操作,不是原子性命令,CPU 执行时第一步是要加载 i 的值,此时 CPU1 cache 中 i 的值为失效状态,那就向总线发出 Read 消息。

  8. CPU2 收到消息后,会构造 Read Response 消息将值传到总线上,同时变为 S 状态。

  9. CPU1 执行 +1 的操作,这个自增 +1 的值是个中间值,并没有直接修改 cacheline 中的 i,此时值为 1,还没执行 i=1 的操作,还未写入 cache 中。

  10. 此时 CPU2 开始执行,S 状态,不需要发出 Read 消息,当执行到自增 +1 操作时,i=1,将要写入 cache 时,向总线发出 Invalidate 消息。

  11. CPU1 收到 Invalidate 消息后,构造 Invalidate Acknowledge 消息,并将当前 cache 修改为 I 状态。

  12. CPU2 收到 CPU1 的 Invalidate Acknowledge 消息,将 cache 置为 E 状态,将 i=1 更新到 cache 中,又修改为 M 状态。

  13. CPU1 接着第 3 步执行,开始写入 cache,发现为 I 状态,那就向总线发出 Read Invalidate 消息。

  14. CPU2 收到消息后,会先将自己缓存行中的数据写入主内存,并响应 Read Response 消息和 Invalidate Acknowledge 消息,同时将相应的缓存行状态更新为 I。

  15. CPU1 收到消息后,缓存行为最新值 i=1,为 E 状态,CPU1 此时并不会重新计算 i++,而是直接将 i=1 写入 cache 中,置为 M 状态。这就出现了线程安全问题。

    分析一下并发时的 cas

假设现在有 CPU1 和 CPU2,需要指向 i++ 的 cas 指令操作,i 初始为 0。CPU1 cache 中和 CPU2 cache 中皆有 i=0 的值,皆为 S 状态。

  1. CPU1 执行 cas 命令,试图修改这个 cache line,则向总线发出 Invalidate 消息。
  2. CPU2 收到消息,将 i 的 cacheline 更改为 I 状态,回复 Invalidate Acknowledge 消息。
  3. CPU1 收到消息,获得所有权,变为 E 状态,开始更新 cache,cas 成功,变为 M 状态。
  4. CPU2 执行 cas 命令,试图修改这个 cache line,此时 cache 为 I 状态,向总线发出 Read Invalidate 消息。
  5. CPU1 收到消息后,会先将自己缓存行中的数据写入主内存,并响应 Read Response 消息和 Invalidate Acknowledge 消息,同时将相应的缓存行状态更新为 I。
  6. CPU2 收到消息后,i 值为最新为 1,cacheline 状态为 E,执行 cas,比较的时候就没通过,内存最新值即此时缓存中 i 为 1 的值与期望的值是 0 不相等,cas 失败,交由上层自旋。

分析的有不对的地方,欢迎指出,一起学习讨论。

参考文章:

  1. 26张图带你彻底搞懂volatile关键字
  2. 39 | MESI协议:如何让多核CPU的高速缓存保持一致?
  3. 聊聊缓存一致性协议
  4. Paul E. McKenney Memory Barriers: a Hardware View for Software Hackers
  5. 浅论Lock 与X86 Cache 一致性
  6. x86 cache locking 的猜想(续)