技术解析

请教大家关于多核并发编程中, cache 一致性的问题
0
2021-06-08 08:25:30
idczone

假设两个线程 A 、B,共享变量 V,A 和 B 分别跑在 CPU1 、CPU2 上,各自 cache 中有 V 所在 cache line 的副本,其中的 V 分别为 V1 、V2 。

那么当 A 直接修改 V,而没有使用锁、CAS 等同步原语,那么 CPU1 只是单纯修改自己的 V1 而没有写回内存,会不会导致 CPU2 的 V2 失效?即一个 CPU 的某块缓存失效的时机是内存发生了修改,还是其他 CPU 的相同缓存发生了修改?

我的理解是因为 cache 的修改写入内存是 write back,而不是立即写入内存,所以没有使用同步原语的情况下,线程 A 对 V1 的修改,应该对内存和线程 B 都是不可见的,直到未来某一时刻 A 的 cache 刷回内存,B 所在的 CPU2 中的 cache line 才会被标记为失效,之后才能读到 A 对 V 的修改。

因此为了保证多个线程美国服务器对共享变量并发操作的可见性,访问共享变量需要同步原语,保证每次写都写到内存上,每次读都读到是内存上最新的值。

这是我从 The Go Memory Model 和 TGPL 书上的理解,不知道理解得对不对,顺便问下大家有没有系统讲解多核缓存的书籍或资料?


因为读 Ardanlabs 的 Go Scheduler 这篇文章( https://www.ardanlabs.com/blog/2018/08/scheduling-in-go-part1.html )时,讲到 cache 时这么写道:
If one Thread on a given core makes a change to its copy of the cache line, then through the magic of hardware, all other copies of the same cache line have to be marked dirty. When a Thread attempts read or write access to a dirty cache line, main memory access (~100 to ~300 clock cycles) is required to get a new copy of the cache line.
修改 cache line 就会导致其他 CPU 的 cache 失效,但我觉得不是 cache 修改写回内存时才会这样吗?不然为什么要使用同步原语同步内存呢?有了困惑。

你问的是 MESI ?

https://www.bilibili.com/video/BV1X54y1Q75J
这个视频是讲的 java 的,里面的缓存失效部分应该是一致的,不知道能不能解答你的疑问

可以看看 MESI. cache 一致性协议应该是硬件(CPU)实现的, 但事实上硬件要做到强一致性同时兼顾性能也很难,所以实现上会弱化为最终一致性, 一般情况下也没有太大问题, 但是在并发多核的情况下,在某些临界点上还是会出现数据不一致的情况. 所以 CPU 设计的时候提供给程序或者编译器内存屏障机制, 来处理这种情况

看下 TSO 宽松内存模型,多核并发的时候,肯定要用机器提供的 mfence lock 等指令来进行强制同步

可以看下读写屏障?。

有意思的讨论,关注一波。我理解与 LZ 有一个不同,即如果没有使用任何同步,线程 B 在忙时,即使 A 中的缓存被刷回内存,B 的缓存一样不会被标记为失效。意思是如果 B 使用的是 while 循环的话那么会永远运行下去

没看文章基于你的疑问和理解。
这些行为基于内存模型的,其模型就是这样。go 的内存模型不太了解,但是比如 java 的 volatile 关键字的语义就是该变量,读取会放弃 cache 中,从内存中刷数据。写会强制刷到内存。至于底层真实情况如何,肯定不同硬件和系统有不同实现。只要在 jave 这一层,表现的是其定义这样就可以了。
“修改 cache line 就会导致其他 CPU 的 cache 失效,但我觉得不是 cache 修改写回内存时才会这样吗?不然为什么要使用同步原语同步内存呢?有了困惑” 这个具体实现有关系啊,“修改 cache line 导致与之相关的 line 失效”就是这样实现的啊,
而你说的“cache line 写回才会导致相关失效” 那是你的理解吧。要我站在 java 内存模型,我会也会觉得不思议,为什么一个 core 的 cache line 写回,其他 core 的就要失效呢?没关系,就是这样,你创建的模型就是这样,我如果我用你这个模型,按照你制定的模型的行为使用即可。

A 写了 v 不加同步原语对 B 不可见是真的,不过不是因为 cache,而是因为可能有其他的 write buffer, cache 一致性是由硬件保证的,不需要同步原语

arm 的不清楚
x86 写是有 buffer 的 这个不是 l123 这种 buffer
你需要 mfence 来清掉 store buffer 和 write combining buffer,然后 mesi 会让这个写对全部 u 可见

看你的描述,和 CPU 没半毛钱关系。
“假设两个线程 A 、B,共享变量 V”,一看就是工作在应用层(进程)。OS 把 CPU 封装成统一的 CPU 资源给应用。应用层一般没办法直接操作到 CPU CACHE 的。

我乱说的,
感觉楼主说的有些类似于 JMM 的规则。
java volatile 是保证属性可见性,线程栈改完就刷到主内存,读也是从主内存拿,其实靠的禁止 cpu 指令重排。
但是 volatile 并不是保证 cas 操作。
cas 是保证替换的时候是期望的条件, 当然会出现 aba 的问题。但也有方式解决,加标记呗。

缓存一致性( Cache Coherence )保证的就是不同 cache 的视图是一样的,所以只要是 cache coherent 的系统,当一个 cache 中的数据发生变化时,其他 cache 中对应的数据也会发生变化。
楼主描述的“线程 A 对 V1 的修改,应该对内存和线程 B 都是不可见的,直到未来某一时刻 A 的 cache 刷回内存,B 所在的 CPU2 中的 cache line 才会被标记为失效,之后才能读到 A 对 V 的修改”不符合缓存一致性的定义。
但是完全实现这一保证,性能会出问题——处理器在执行 store 操作之前,需要告诉所有其他处理器这个 cacheline 被 invalidate 了,然后还需要等其他处理器回复,才能完成 store 操作。但是大多数情况下我并不需要等这个 store 。所以就出现了 store buffer 缓存 store 操作,但是为了保证本处理器的程序顺序正常,store buffer 必须配合 load-to-store forwarding,然后就出现了本处理器和其他处理器所感知到的程序顺序不一样的问题,以及可见性的问题。
再往后去看 Memory Barriers: a Hardware View for Software Hackers
当然好像在各路 Java 教材中,“懒得刷进主存的 ‘cache’”事实上替 store buffer 之类的优化背了锅。

s/load-to-store forwarding/store-to-load forwarding
还有忘了说 ... 因为 store buffer 的存在,一般的 store 操作在不填满 store buffer 的前提下的开销是很小的,和 load 差远了。
“访问共享变量需要同步原语”只是因为内存模型不保证未保护的访问,这是概念层面而非实现层面的问题。当然内存模型是综合考虑软件需求和硬件实现才提出的,所以折腾实现也是有用的。
据说 ARM 已经在架构层面加入了目前最流行的 sequential consistency 模型,不知道效果如何。

差不多是你说的那样, 可以搜下 MESI

4 楼正解。正好前段时间我也深入研究了这个问题,并且整理成了一篇博客: https://blog.fanscore.cn/p/34/ 楼主可以参考下,或许对你有帮助
数据地带为您的网站提供全球顶级IDC资源
在线咨询
专属客服