写点什么

面试不踩坑!Volatile 的内存屏障源码级详解

用户头像
Java王路飞
关注
发布于: 2021 年 02 月 15 日
面试不踩坑!Volatile的内存屏障源码级详解

请看下面的代码并尝试猜测输出:

可能一看下面的代码你可能会放弃继续看了,但如果你想要彻底弄明白 volatile,你需要耐心,下面的代码很简单!

在下面的代码中,我们定义了 4 个字段 x,y,a 和 b,它们被初始化为 0

然后,我们创建 2 个分别调用 Test1 和 Test2 的任务,并等待两个任务完成。

完成两个任务后,我们检查 a 和 b 是否仍为 0,

如果是,则打印它们的值。

最后,我们将所有内容重置为 0,然后一次又一次地运行相同的循环。

using System;using System.Threading;using System.Threading.Tasks;
namespace MemoryBarriers{ class Program { static volatile int x, y, a, b; static void Main() { while (true) { var t1 = Task.Run(Test1); var t2 = Task.Run(Test2); Task.WaitAll(t1, t2); if (a == 0 && b == 0) { Console.WriteLine("{0}, {1}", a, b); } x = y = a = b = 0; } }
static void Test1() { x = 1; // Interlocked.MemoryBarrierProcessWide(); a = y; }
static void Test2() { y = 1; b = x; } }
复制代码

如果您运行上述代码(最好在 Release 模式下运行),则会看到输出为 0、0 的许多输出,如下图。

我们先根据代码自我分析一下

在 Test1 中,我们将 x 设置为 1,将 a 设置为 y,而 Test2 将 y 设置为 1,将 b 设置为 x

因此这 4 条语句会在 2 个线程中竞争

罗列下可能会发生的几种情况:

1. Test1 先于 Test2 执行:

x = 1a = yy = 1b = x
复制代码

在这种情况下,我们假设 Test1 在 Test2 之前完成,那么最终值将是

x = 1,a = 0,y = 1,b = 1
复制代码

2. Test2 执行完成后执行 Test1:

y = 1 b = xx = 1a = y
复制代码

在这种情况下,那么最终值将是

x = 1,a = 1,y = 1,b = 0
复制代码

2. Test1 执行期间执行 Test2:

x = 1y = 1b = xa = y
复制代码

在这种情况下,那么最终值将是

x = 1,a = 1,y = 1,b = 1
复制代码

3. Test2 执行期间执行 Test1

y = 1x = 1a = yb = x
复制代码

在这种情况下,那么最终值将是

x = 1,a = 1,y = 1,b = 1
复制代码

4. Test1 交织 Test2

x = 1y = 1a = yb = x
复制代码

在这种情况下,那么最终值将是

x = 1,a = 1,y = 1,b = 1
复制代码

5.Test2 交织 Test1

y = 1x = 1b = xa = y
复制代码

在这种情况下,那么最终值将是

x = 1,a = 1,y = 1,b = 1
复制代码

我认为上面已经罗列的

已经涵盖了所有可能的情况,

但是无论发生哪种竞争情况,

看起来一旦两个任务都完成,

就不可能使 a 和 b 都同时为零,

但是奇迹般地,居然一直在打印 0,0 (请看上面的动图,如果你怀疑的话代码 copy 执行试试)

真相永远只有一个

先揭晓答案:cpu 的 乱序执行

让我们看一下 Test1 和 Test2 的 IL 中间代码。

我在相关部分中添加了注释。

#ConsoleApp9.Program.Test1()    #function prolog ommitted    L0015: mov dword ptr [rax+8], 1   # 把值 1 上传到内存地址 'x'    L001c: mov edx, [rax+0xc]         # 从内存地址 'y' 下载值并放到edx(寄存器)    L001f: mov [rax+0x10], edx.       # 从(edx)寄存器把值上传到内存地址 'a'    L0022: add rsp, 0x28.               L0026: ret
#ConsoleApp9.Program.Test2() #function prolog L0015: mov dword ptr [rax+0xc], 1 # 把值 1 上传到内存地址 'y' L001c: mov edx, [rax+8]. # 从内存地址 'x' 下载值并放到edx(寄存器) L001f: mov [rax+0x14], edx. # 从(edx)寄存器把值上传到内存地址 'b' L0022: add rsp, 0x28 L0026: ret
复制代码

请注意,我在注释中使用“上载”和“下载”一词,而不是传统的读/写术语。

为了从变量中读取值并将其分配到另一个存储位置,

我们必须将其读取到 CPU 寄存器(如上面的 edx),

然后才能将其分配给目标变量。

由于 CPU 操作非常快,因此与在 CPU 中执行的操作相比,对内存的读取或写入真的很慢。

所以我使用“上传”和“下载”,相对于 CPU 的高速缓存而言【读取和写入内存的行为】

就像我们向远程 Web 服务上载或从中下载一样慢。

以下是各项指标(2020 年数据)(ns 为纳秒)

L1 cache reference: 1 ns

L2 cache reference: 4 ns

Branch mispredict: 3 ns

Mutex lock/unlock: 17 ns

Main memory reference: 100 ns

Compress 1K bytes with Zippy: 2000 ns

Send 2K bytes over commodity network: 44 ns

Read 1 MB sequentially from memory: 3000 ns

Round trip within same datacenter: 500,000 ns

Disk seek: 2,000,000 ns

Read 1 MB sequentially from disk: 825,000 ns

Read 1 MB sequentially from SSD: 49000 ns

由此可见 访问主内存比访问 CPU 缓存中的内容慢 100 倍

如果让你开发一个应用程序,实现上载或者下载功能。

您将如何设计此?肯定想要开多线程,并行化执行以节省时间!

这正是 CPU 的功能。CPU 被我们设计得很聪明,

在实际运行中可以确定某些“上载”和“下载”操作(指令)不会互相影响,

并且 CPU 为了节省时间,对它们(指令)进行了(优化)并行处理,

也叫【cpu 乱序执行】(out-of-order)

上面我说道:在实际运行中可以确定某些“上载”和“下载”操作(指令)不会互相影响,

这里有一个前提条件哈:该假设仅基于基于线程的依赖性检查进行(per-thread basis dependency checks)。

虽然在单个线程是可以被确定为指令独立性,但 CPU 无法考虑多个线程的情况,所以提供了【volatile 关键字】

我们回到上面的示例,尽管我们已将字段标记为 volatile,但感觉上没有起作用。为什么?

一般说到 volatile 我都一般都会举下面的例子(内存可见性)

using System;using System.Threading;public class C {    bool completed;    static void Main()    {      C c = new C();      var t = new Thread (() =>      {        bool toggle = false;        while (!c.completed) toggle = !toggle;      });      t.Start();      Thread.Sleep (1000);      c.completed = true;      t.Join();        // Blocks indefinitely    }}
复制代码

如果您使用 release 模式运行上述代码,它也会无限死循环。

这次 CPU 没有罪,但罪魁祸首是 JIT 优化。

你如果把:

bool completed;
复制代码

改成

volatile bool completed;
复制代码

就不会死循环了。

让我们来看一下[没有加 volatile]和[加了 volatile]这 2 种情况的 IL 代码:

没有加 volatile

L0000: xor eax, eaxL0002: mov rdx, [rcx+8]L0006: movzx edx, byte ptr [rdx+8]L000a: test edx, edxL000c: jne short L001aL000e: test eax, eax L0010: sete alL0013: movzx eax, alL0016: test edx, edx # <-- 注意看这里L0018: je short L000eL001a: ret
复制代码

加了 volatile

L0000: xor eax, eaxL0002: mov rdx, [rcx+8]L0006: cmp byte ptr [rdx+8], 0L000a: jne short L001eL000c: mov rdx, [rcx+8]L0010: test eax, eaxL0012: sete alL0015: movzx eax, alL0018: cmp byte ptr [rdx+8], 0  <-- 注意看这里L001c: je short L0010L001e: ret
复制代码

留意我打了注释的那行。上面的这些 IL 代码行 实际上是代码进行检查的地方:

while (!c.completed)
复制代码

当不使用 volatile 时,JIT 将完成的值缓存到寄存器(edx),然后仅使用 edx 寄存器的值来判断(while (!c.completed))。

但是,当我们使用 volatile 时,将强制 JIT 不进行缓存,

而是每次我们需要读取它直接访问内存的值 (cmp byte ptr [rdx+8], 0)

JIT 缓存到寄存器 是因为 发现了 内存访问的速度慢了 100 倍以上,就像 CPU 一样,JIT 出于良好的意图,缓存了变量。

因此它无法检测到别的线程中的修改。

volatile 解决了这里的问题,迫使 JIT 不进行缓存。

说完可见性了我们在来说下 volatile 的另外一个特性:内存屏障

  1. 确保在执行下一个上传/下载指令之前,已完成从 volatile 变量的下载指令。

  2. 确保在执行对​volatile 变量的当前上传指令之前,完成了上一个上传/下载指令。

但是 volatile 并不禁止在完成上一条上传指令之前完成对 volatile 变量的下载指令。

CPU 可以并行执行并可以继续执行任何先执行的操作。

正是由于 volatile 关键字无法阻止,所以这就是这里发生的情况:

mov dword ptr [rax+0xc], 1  # 把值 1 上传到内存地址 'y'mov edx, [rax+8].           # 从内存地址 'x' 下载值并放到edx(寄存器)
复制代码

变成这个

mov edx, [rax+8].           # 从内存地址 'x' 下载值并放到edx(寄存器)mov dword ptr [rax+0xc], 1  # 把值 1 上传到内存地址 'y'
复制代码

因此,由于 CPU 认为这些指令是独立的,因此在 y 更新之前先读取 x,同理在 Test1 方法也是会发生 x 更新之前先读取 y。

所以才会出现本文例子的坑~~!

如何解决?

输入内存屏障 内存屏障是对 CPU 的一种特殊锁定指令,它禁止指令在该屏障上重新排序。因此,该程序将按预期方式运行,但缺点是会慢几十纳秒。

在我们的示例中,注释了一行代码:

//Interlocked.MemoryBarrierProcessWide();
复制代码

如果取消注释该行,程序将正常运行~~~~~

总结

平常我们说 volatile 一般很容易去理解它的内存可见性,很难理解内存屏障这个概念,内存屏障的概念中对于 volatile 变量的赋值,

volatile 并不禁止在完成上一条上传指令之前完成对 volatile 变量的下载指令。这个在多线程环境下一定得注意!

原文链接:http://www.cnblogs.com/yudongdong/p/14403512.html

如果觉得本文对你有帮助,可以关注一下我公众号,回复关键字【面试】即可得到一份 Java 核心知识点整理与一份面试大礼包!另有更多技术干货文章以及相关资料共享,大家一起学习进步!


发布于: 2021 年 02 月 15 日阅读数: 36
用户头像

Java王路飞

关注

需要资料添加小助理vx:17375779923 即可 2021.01.29 加入

Java领域;架构知识;面试心得;互联网行业最新资讯

评论 (1 条评论)

发布
用户头像
面试不踩坑!Volatile 的内存屏障源码级详解
2021 年 02 月 15 日 17:54
回复
没有更多了
面试不踩坑!Volatile的内存屏障源码级详解