乐趣区

关于c#:对精致码农大佬的-理解-volatile-关键字-文章结论的思考和寻找真相

一:背景

1. 讲故事

昨天在园里的编辑头条看到 粗劣码农大佬 写的一篇题为:[C#.NET 拾遗补漏]10:了解 volatile 关键字 (https://www.cnblogs.com/willi… 的文章,大略就是说在 多线程环境下,一个在 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/huang…,那篇文章我剖析是因为 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:\net5\ConsoleApp1\ConsoleApp1\bin\x64\Release\netcoreapp3.1\ConsoleApp1.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

退出移动版