zoukankan      html  css  js  c++  java
  • 对精致码农大佬的 [理解 volatile 关键字] 文章结论的思考和寻找真相

    一:背景

    1. 讲故事

    昨天在园里的编辑头条看到 精致码农大佬 写的一篇题为:[C#.NET 拾遗补漏]10:理解 volatile 关键字 (https://www.cnblogs.com/willick/p/13889006.html) 的文章,大概就是说在 多线程环境下,一个在debug不出现,在release中出现的bug,原文代码如下:

    
    public class Worker
    {
        private bool _shouldStop;
    
        public void DoWork()
        {
            bool work = false;
            // 注意:这里会被编译器优化为 while(true)
            while (!_shouldStop)
            {
                work = !work; // do sth.
            }
            Console.WriteLine("工作线程:正在终止...");
        }
    
        public void RequestStop()
        {
            _shouldStop = true;
        }
    }
    
    public class Program
    {
        public static void Main()
        {
            var worker = new Worker();
    
            Console.WriteLine("主线程:启动工作线程...");
            var workerTask = Task.Run(worker.DoWork);
    
            // 等待 500 毫秒以确保工作线程已在执行
            Thread.Sleep(500);
    
            Console.WriteLine("主线程:请求终止工作线程...");
            worker.RequestStop();
    
            // 待待工作线程执行结束
            workerTask.Wait();
            //workerThread.Join();
    
            Console.WriteLine("主线程:工作线程已终止");
        }
    }
    
    

    文中分析这个bug是因为在 release 环境下,jit做了 while (!_shouldStop) -> while(true) 的代码优化。

    2. 我的质疑

    为什么我对这个问题比较敏感呢?第一:这是一个经典的问题,第二:我在 2017-03-20 也写过一篇这样的文章: 享受release版本发布的好处的同时也应该警惕release可能给你引入一些莫名其妙的大bug (https://www.cnblogs.com/huangxincheng/p/6585907.html) ,那篇文章我分析是因为 cpu缓存 和 内存 两者之间不一致导致的脏读,显然和大佬的结论大相径庭,而且两篇文章都存在一个问题,就是草率的下结论,并没有拿出一个完整的证据链来证明真的是这样, 这篇文章的目的就是试着拿出我认为的证据链。

    二:真的被优化为 while(true) 了吗

    1. 从两次编译阶段中寻找答案

    大家应该都知道代码会经历两个阶段的编译: 第一阶段:编译器会把 C# code 编译成 MSIL 代码 ,第二阶段: CLR 会启动 JIT 将 MSIL 编译成机器代码,画一张图如下:

    既然大佬说被优化成 while(true) 了,那意思就是说要么在 MSIL 中被优化,要么在 机器码 中被优化,这里我可以用 ILSpy 和 Windbg 去挖一挖,看看大佬说的是否正确?

    2. 用 ILSpy 查看 MSIL 是否被优化

    把项目编译成 release 模式,直接查看 DoWork() 的MSIL,如下所示:

    
    .method public hidebysig 
    	instance void DoWork () cil managed 
    {
    	// Method begins at RVA 0x2048
    	// Code size 28 (0x1c)
    	.maxstack 2
    	.locals init (
    		[0] bool work
    	)
    
    	IL_0000: ldc.i4.0
    	IL_0001: stloc.0
    	IL_0002: br.s IL_0009
    	// loop start (head: IL_0009)
    		IL_0004: ldloc.0
    		IL_0005: ldc.i4.0
    		IL_0006: ceq
    		IL_0008: stloc.0
    
    		IL_0009: ldarg.0
    		IL_000a: ldfld bool ConsoleApp1.Worker::_shouldStop
    		IL_000f: brfalse.s IL_0004
    	// end loop
    
    	IL_0011: ldstr "工作线程:正在终止..."
    	IL_0016: call void [System.Console]System.Console::WriteLine(string)
    	IL_001b: ret
    } // end of method Worker::DoWork
    
    
    

    从这句: ldfld bool ConsoleApp1.Worker::_shouldStop 可看出,代码并没有做任何优化,有点遗憾继续看看第二阶段。

    3. 使用 windbg 查看 机器码 是否被优化

    很显然机器码给大家看也看不懂,只能看被 JIT 编译成 机器代码 的 汇编代码,废话不多说,生成一个 dump 文件.

    • 用 name2ee 查看 DoWork 的方法描述符
    
    0:011> !name2ee ConsoleApp1!Worker.DoWork
    Module:      00007ffc8fdaf7e0
    Assembly:    ConsoleApp1.dll
    Token:       0000000006000001
    MethodDesc:  00007ffc8fdd3a50
    Name:        ConsoleApp1.Worker.DoWork()
    JITTED Code Address: 00007ffc8fd17500
    
    

    JITTED Code Address: 00007ffc8fd17500 可以看到,DoWork 已经被 JIT 编译过了,好事情。

    • 用 !U 查看 DoWork 的反汇编

    对照代码图可以看到

    • ecx 寄存器 存放着 _shouldStop 值.
    • eax 寄存器 存放着 work 值

    既然有两个寄存器存放着两个值,也就说明 while (!_shouldStop) -> while(true) 这个说法是站不住脚的。。。 那真相是什么呢? 我试着揭晓。

    三:我所谓的真相

    1. 验证寄存器的值

    很明显当前的程序正在死循环,说明_shouldStop变量此时肯定是false,为了验证是否正确,通过 r 命令查看一下此时寄存器的值。

    
    0:011> r ecx
    ecx=0
    
    

    2. 验证内存中的 _shouldStop 的值

    要想验证内存中的 _shouldStop 是否已经为 true,最简单的办法就是去 托管堆 找 Work 对象,看看它的实例变量 _shouldStop 是否为 true 即可。

    
    0:011> !dumpheap -stat
    Statistics:
                  MT    Count    TotalSize Class Name
    00007ffc8fdd3a90        1           24 ConsoleApp1.Worker
    
    0:011> !dumpheap -mt 00007ffc8fdd3a90
             Address               MT     Size
    000001ee59f4abd8 00007ffc8fdd3a90       24     
    
    0:011> !do 000001ee59f4abd8
    Name:        ConsoleApp1.Worker
    MethodTable: 00007ffc8fdd3a90
    EEClass:     00007ffc8fdccda8
    Size:        24(0x18) bytes
    File:        E:
    et5ConsoleApp1ConsoleApp1inx64Release
    etcoreapp3.1ConsoleApp1.dll
    Fields:
                  MT    Field   Offset                 Type VT     Attr            Value Name
    00007ffc8fcd71d0  4000001        8       System.Boolean  1 instance                1 _shouldStop
    
    

    从最后一行代码可以看到: _shouldStop =1 , 证明内存中的 _shouldStop 确实为 true,没毛病!

    3. 整体思路

    到这里是不是已经非常清晰了,由于while循环太频繁了,release做了代码优化,将 _shouldStop 的值直接放在了 ecx 寄存器中, 当B线程执行 _shouldStop=true 更新到内存的时候,并没有什么通知机制,导致A线程在不知情的情况下一直读自己的 ecx 寄存器的值0,这时候就脏读了,脑子里是不是有一张蓝图? 大概就像下面这样:

    思想知道了,解决这个问题也就简单了,给 _shouldStop 打上 volatile 标记,让cpu每次都到内存中取 _shouldStop 值即可,

    
    private volatile bool _shouldStop;
    
    

    然后再看 Dowork 的反汇编:

    为了更加可视化,来张对比图,很明显可以看到, volatile之前是直接取值比较,volatile之后是取偏移地址上的值比较,这就是真相吧!

    四:总结

    总的来说还是脏读引起的问题,刚好也补充了之前文章未寻找真相的一个遗憾吧,也感谢 精致码农大佬 原创输出。

    更多高质量干货:参见我的 GitHub: dotnetfly

    图片名称
  • 相关阅读:
    小白学phoneGap《构建跨平台APP:phoneGap移动应用实战》连载一(PhoneGap中的API)
    有一种蓝,是神往,是心醉,是心伤
    persits.jpeg 水印组件
    SD卡操作相关的工具SDCardUtils
    apollo 消息分发源代码分析
    tcp ip协议笔记(1)——简单介绍
    百度地图SDK调试SDKInitializer.initialize(getApplicationContext())错误
    一气呵成编完代码的感觉对不正确
    多线程编程1-NSThread
    VIP的转移
  • 原文地址:https://www.cnblogs.com/huangxincheng/p/13903671.html
Copyright © 2011-2022 走看看