找回密码
 立即注册

QQ登录

只需一步,快速开始

搜索
查看: 272940|回复: 0

可能造成十万级数据混乱的难题,码农:都是缓存惹的祸 ...

[复制链接]

该用户从未签到

发表于 2021-4-9 18:48:25 | 显示全部楼层 |阅读模式

您需要 登录 才可以下载或查看,没有账号?立即注册

×
[indent,文:低并发编程
源:《volatile 三部曲之可见性》
[/indent,缓存有效的解决了速度不同的设备之间的访问问题,但是它也带来了更多的问题,今天介绍一个缓存引起的数据不一致问题以及对应的解决方法。
友情提示:本文基于 Java 语言,CPU 基于 x86 架构。
有一个内存,在其 0x400 位置处,存储着数字 1

                               
登录/注册后可看大图

有一个处理器,从内存中读数据到寄存器时,会将读到的数据在缓存中存储一份。

                               
登录/注册后可看大图

现在,这个处理器读取到了三条机器指令,将内存中的数字改写为了 2

                               
登录/注册后可看大图

我们看到,这个写的过程被细化成了两步,需要先写到处理器缓存,再从缓存刷新到内存。
同样对于读来说,也需要先读缓存,如果读不到再去内存中获取,同时更新缓存。
这样,对于单个处理器来说,由于缓存的存在,读写效率都有所提升。
可是,如果有另一个处理器呢?

                               
登录/注册后可看大图

场景一:处理器 1 未及时将缓存中的值刷新到内存,导致处理器 2 读到了内存中的旧值。

                               
登录/注册后可看大图

场景二:处理器 1 及时刷新缓存到了内存,但处理器 2 读的是自己缓存中的旧值。

                               
登录/注册后可看大图

可以看到,这两种场景,都是处理器 1 认为,已经将共享变量改写为了 2,但处理器 2 读到的值仍然是 1。
换句话说,处理器 1 对这个共享变量的修改,对处理器 2 来说"不可见"。
现在我们加入线程的概念,假设线程 1 运行在处理器 1,线程 2 运行在处理器 2。

                               
登录/注册后可看大图

那么就可以说:
线程 1 对这个共享变量的修改,对线程 2 来说"不可见"。
这个问题,就被称为可见性问题。
假如线程 1 对共享变量的修改,线程 2 立刻就能够看到。
那么就可以说,这个共享变量,具有可见性。
那如何做到这一点呢?
我们首先想想看,刚刚的两个场景,为什么不可见。
1. 线程 1 对共享变量的修改,如果刚刚将其值写入自己的缓存,却还没有刷新到内存,此时内存的值仍为旧值。
2. 即使线程 1 将其修改后的值,从缓存刷新到了内存,但线程 2 仍然从自己的缓存中读取,读到的也可能是旧值。
所以,问题就出在这两个地方。

                               
登录/注册后可看大图

那要解决这个问题也非常简单,只需要在线程 1 将共享变量进行写操作时,产生如下两个效果即可。
1. 线程 1 将新值写入缓存后,立刻刷新到内存中。
2. 这个写入内存的操作,使线程 2 的缓存无效。若想读取该共享变量,则需要重新从内存中获取。

                               
登录/注册后可看大图

这样,该共享变量,就具有了可见性。
那如何使得,一个线程在进行写操作时,有上述两个效果呢?
答案是 LOCK 指令。
假如,线程 1 执行了如下指令,将内存中某地址处的值+1。
add [某内存地址,, 1现在这个写操作,不会立即刷新到内存,也不会将其他处理器中的缓存失效,也即不具备可见性。
那只需要加上一个 LOCK 前缀。
lock add [某内存地址,, 1这样,这个操作就会使得:
1. 立即将该处理器缓存(具体说是缓存行)中的数据刷新到内存。
2. 使得其他处理器缓存(具体说是缓存了该内存地址的缓存行)失效。
第一步将缓存刷新到内存后,使得其他处理器缓存失效,也就是第二步的发生,是利用了 CPU 的缓存一致性协议。
而为了实现缓存一致性协议,每个处理器通常的一个做法是,通过监听在总线上传播的数据来判断自己的缓存值是否过期,这种方式叫总线嗅探机制。
总之,这两个效果一出,在程序员或者线程的眼中,就变成了可见性的保证。
JMM现在,让我们来到 Java 语言的世界。
上面那些处理器、寄存器、缓存等,都是硬件层面的概念,如果把这些无聊的、难学的细节,暴露给程序员,估计 Java 就无法流行起来了吧。
Java 可不希望这种情况发生,于是发明了一个简单的、抽象的内存模型,来屏蔽这些硬件层面的细节。
这个内存模型就叫做 JMM,Java Memory Module。

                               
登录/注册后可看大图

一个线程写入一个共享变量时,需要先写入自己的本地内存,再刷新到主内存。默认情况下,JMM 并不会保证什么时候刷新到主内存。
同样,一个线程读一个共享变量时,需要先读取自己的本地内存,如果读不到再去主内存中读取,同时更新到自己的本地内存。
有同学就要问了,这个本地内存,是在内存中开辟的一块空间么?一个线程读一个内存中的数据,还需要从内存一个地方拷贝到另一个地方?

                               
登录/注册后可看大图
为啥上面有个×?因为怕有的人把这个图当成正解了...
注意,JMM 是语言级的内存模型,所以你千万不能把这个模型中的概念,同真实的硬件层的概念相关联,这也是很多同学对此感到迷惑的根源。
JMM 的出现,就是为了让程序员不要去想硬件上的细节,但这样的命名方式,反而使程序员理解起来更加困惑了。
如果非要对应硬件上的原理,那不准确地说,这里的本地内存实际上在并不真实存在,是由于处理器中的缓存机制而产生的抽象概念。这么说可能稍稍解决你的一点点困惑。
之所以说不准确,一是因为处理器有很多不同的架构,并不一定所有的架构都有缓存。二是因为除了缓存之外,还有其他硬件和编译器的优化,可以导致本地内存这个概念的存在。
所以从某种程度上说,JMM 还确实是大大简化和屏蔽了程序员对于硬件细节的了解。
根据 JMM 向程序员提供的抽象模型,我们可以推测出如下问题。

                               
登录/注册后可看大图

此时线程 2 并没有读到线程 1 写入的最新值,a=2,而是读到了主内存中的旧值,a=1。
也即,线程 1 对共享变量的写入,对线程 2 不可见。
那么在 Java 中,如何让一个共享变量具有上述的可见性呢?
答案是加一个 volatile 即可。
简单说,Java 语言为了确保共享变量得到一致和可靠的更新,可以通过锁,也可以通过更轻量的 volatile 关键字。
比如在一个变量 a 前面加上了 volatile 关键字
volatile int a;那么在写这个 volatile 变量时,JMM 会把该线程对应的本地内存中的共享变量值立即刷新到主内存。

                               
登录/注册后可看大图

相应地,当读一个 volatile 变量时,JMM 会把该线程对应的本地内存置为无效。线程接下来将从主内存中读取共享变量。

                               
登录/注册后可看大图

以上两点,就是 volatile 的内存语义。
而这两点的实质上,是完成了一次线程间通信,即线程 1 向线程 2 发送了消息。

                               
登录/注册后可看大图

有的同学可能又要问了,内存语义,那真的是写的时候刷新到主内存,而读的时候让本地内存失效么?
这里我还是要强调,JMM 是语言级的内存模型,无论它硬件层面上是怎么去保证的,在你站在语言层面去学习 JMM 时,就不要去想硬件细节。
为了解决部分同学的困惑,我还是用不准确的语言来说一下,volatile 的底层会被转化成上面所说的 LOCK 指令,写这个共享变量时,就既做了刷新到主内存,同时也将其他处理器缓存失效的操作,并不是写的时候刷新缓存,读的时候再去将本地内存失效。
但在语言层去描述 volatile 的内存语义时,刚刚的说法完全没错,只要程序员按照 JMM 这个内存模型和 volatile 的内存语义去编程,能够方便理解,且能够达到预期的效果,即可。至于是不是准确表达了硬件层面的原理,这个是不重要的。
这让我想到了之前看过的一个演讲,我记得叫“眼见为实”,是说我们看到的,并不一定是这个宇宙的真实面貌,只是能让我们更好地生存并延续后代,而已。
回复

使用道具 举报

网站地图|页面地图|文字地图|Archiver|手机版|小黑屋|找资源 |网站地图

GMT+8, 2024-12-22 22:16

Powered by Discuz! X3.5

© 2001-2024 Discuz! Team.

快速回复 返回顶部 返回列表