C#执行顺序带来的潜在问题

编写程序的时候,人们的直观感觉通常认为,程序的执行顺序是按照语句的顺序进行的。然而,许多编程语言的规范是允许实际执行顺序与语句编写顺序不符的。实际上,编译器为了完成某种优化,常常会对一些操作进行适当的顺序调整,导致一些预料之外的现象。

实验现象

首先,通过一个例子来展示这个现象。在一个C# .NET Core 3.1命令行程序中,定义两个全局变量ab,在线程1中,依次对ba进行递增。这样,在任何时刻b应当等于aa+1

        static int a = 0;
        static int b = 0;

        static void Thread1()
        {
            while (true)
            {
                ++b;
                ++a;
            }
        }

在线程2中,先读取a的值,然后执行一些其他操作,再读取b的值。如果语句一定是按顺序执行的,那么读取到的b的值应当比读取到的a的值更新,从而b必然大于或等于a(除非b发生了溢出)。编写程序,当b < a时输出它们的值。

    static int c = 0;

    static void Thread2()
    {
        while (true)
        {
            c += b;
            var localA = a;
            c += b;
            var localB = b;
            if (localA > localB)
            {
                Console.WriteLine($"a={localA} b={localB}");
            }
        }
    }

再编写主程序,启动上述的两个线程。

        static void Main(string[] args)
        {
            Task.Run(Thread1);
            Task.Run(Thread2);

            Console.ReadKey();
        }

使用Debug配置,编译并运行该程序,命令行是没有输出的,符合我们的预期。但是使用Release配置的话,就会出现大量输出,其中a的值比b大1到5不等。

查看反汇编可以看到,在第1个c += b语句处,程序将b的值放到了寄存器中,而后面的语句均使用了该寄存器内存放的值。所以,编译器实际上将对b的读取操作合并并且前置了。以下为反汇编结果片段。

00007FFB628A394D  mov         rcx,7FFB6292FBD0h  
00007FFB628A3957  mov         edx,1  
00007FFB628A395C  call        00007FFBC2387B10  
00007FFB628A3961  mov         esi,dword ptr [7FFB6292FC08h]  
00007FFB628A3967  mov         ecx,esi  
00007FFB628A3969  add         ecx,dword ptr [7FFB6292FC0Ch]  
00007FFB628A396F  mov         dword ptr [7FFB6292FC0Ch],ecx  
                var localA = a;
00007FFB628A3975  mov         edi,dword ptr [7FFB6292FC04h]  
                c += b;
00007FFB628A397B  add         ecx,esi  
                c += b;
00007FFB628A397D  mov         dword ptr [7FFB6292FC0Ch],ecx  
                if (localA > localB)
00007FFB628A3983  cmp         edi,esi  
00007FFB628A3985  jle         00007FFB628A394D  

理论分析

在C#语言标准的Basic concepts一章Execution order一节(参见:Basic concepts – C# language specification)中,提到了C#的执行顺序规范。C#程序的副作用在以下关键点处的顺序是被保留的:

  • 对volatile字段的读写
  • lock语句
  • 线程的创建和终结

C#程序的执行顺序在满足以下条件的情况下,可以由执行环境任意调整的:

  • 在同一线程内,数据的的依赖关系是被保留的。即,结果与语句按照顺序执行的情况一致。
  • 初始化顺序的规则是被保留的。
  • 相对于volatile字段的读写,副作用的顺序是被保留的。

而上述的副作用包括:

  • 读取或写入volatile字段
  • 写入非volatile变量
  • 写入外部资源
  • 抛出异常

由此可以推出,C#程序中对非volatile变量的读取顺序可能会被调整。在只有一个线程对该变量进行操作时,这个顺序的调整是保证不会影响结果的;但如果同时有其他的线程正在对变量进行修改,则读取的顺序是无法确定的。

因此,如果有多个线程同时访问的,对值的实时性有要求的变量,应当设置为volatile变量。将上述实验中的静态变量ab改为volatile变量后,即使是Release配置下,也不会出现命令行的输出,即两个变量的读取顺序符合原始的语句顺序。

结论

在C#程序中,读取非volatile变量的顺序可能被执行环境任意调整。如果某个变量在被读取的时候会被其他线程写入,为了该读取结果的实时性,应当将该变量设置为volatile变量。

留言

有想法?请给我们留言!您的留言不会直接显示在网站内。