16

对精致码农大佬的 [理解 volatile 关键字] 文章结论的思考和寻找真相

 3 years ago
source link: https://www.cnblogs.com/huangxincheng/p/13903671.html
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

对精致码农大佬的 [理解 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 编译成机器代码,画一张图如下:

214741-20201030183944430-2039461280.png

既然大佬说被优化成 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 的反汇编
214741-20201030183944839-679911655.png

对照代码图可以看到

  • 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 的反汇编:

214741-20201030183945294-100449814.png

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

214741-20201030183945462-1770101033.png

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

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

图片名称

About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK