多线程并发编程“怪”事


前言

之前写过一篇多线程并发编程的文章,讲的是如何用“锁”来解决线程安全问题及“锁”的原理。感兴趣的同学可以点击多线程并发编程“锁”事再回顾一下。多线程并发编程其实还有很多东西可讲,所以我觉得可以写一系列文章,将多线程并发编程掰开来揉碎了,讲述其中的原理,指出其中的坑,总结正确的用法等,让大家在实际的开发中受益。本章我们讲一个多线程并发编程中的“怪”事。


一、奇怪,编译模式竟然会影响程序执行结果

先来看下面一段代码,实现的逻辑大概是一个线程在等待1秒后,将变量result值改为2。并且修改变量finished值为true,来结束掉主线程的死循环,输出result值,然后程序结束运行。

using System;
using System.Threading;

class Program
{
    public static int result;   
    public static bool finished;

    static void Thread2() 
    {
        Task.Delay(1000).Wait();
        result = 2;    
        finished = true; 
    }

    static void Main() 
    {
        new Thread(new ThreadStart(Thread2)).Start();

        for (;;) 
        {
            if (finished) 
            {
                Console.WriteLine("result = {0}", result);
                return;
            }
        }
    }
}

我们发现在Debug编译模式下这段代码执行没有任何问题,能够正常输出result = 2,并且程序结束运行。在这里插入图片描述

但改为Release编译模式后,程序总是不能结束运行。
Release编译模式

我们知道,Debug和Release 是构建代码时的两个不同的编译选项,Debug 为调试版本,一般用于开发调试环境,它包含调试信息,并且不作任何优化,便于程序员调试程序。Release 为发布版本,一般用于生产环境,它往往会进行各种优化,使得程序在代码大小和运行速度上都是最优的。这种优化通常包含对一些CPU指令的优化,比如调整CPU指令的执行顺序以达到它认为的更优,还有对数据的优化,比如说将一些变量或参数的值放在CPU的寄存器中,来提升读取数据的性能等。这种优化在绝大多数情况下都不会出问题,但有时我们可能会碰到奇怪的情况,正如上边的示例一样,我们就得需要从编译器优化这方面来考虑。

二、Release模式下编译器是如何优化的

C#代码经过了两个编译过程,我们分别探究一下,Release模式在这两个阶段都做了什么。
在这里插入图片描述

1.用dnSpy查看IL代码

以下是Thread2和Main方法的IL代码,无论是Debug还是Release模式,基本是正常翻译了我们的源代码,没有看到优化的痕迹。

关键IL代码解析:
Thread2方法:
IL_0015: ldc.i4.1 将整数值 1 压入堆栈。
IL_0016: stsfld bool Program::finished 取出堆栈中的值,赋值给静态字段finished。

Main方法:
IL_0016: ldsfld bool Program::finished,将静态字段finished的值压入堆栈。
IL_001B: brfalse.s IL_0016 判断堆栈上的值,如果为false,则将控制转移到目标指令IL_0016,以此来实现循环。

.method private hidebysig static 
		void Thread2 () cil managed 
	{
		// Header Size: 1 byte
		// Code Size: 28 (0x1C) bytes
		.maxstack 8

		/* (11,9)-(11,33) C:\Users\andy\source\repos\ConsoleApp1\ConsoleApp1\Program.cs */
		/* 0x00000251 20E8030000   */ IL_0000: ldc.i4    1000
		/* 0x00000256 280B00000A   */ IL_0005: call      class [System.Runtime]System.Threading.Tasks.Task [System.Runtime]System.Threading.Tasks.Task::Delay(int32)
		/* 0x0000025B 6F0C00000A   */ IL_000A: callvirt  instance void [System.Runtime]System.Threading.Tasks.Task::Wait()
		/* (12,9)-(12,20) C:\Users\andy\source\repos\ConsoleApp1\ConsoleApp1\Program.cs */
		/* 0x00000260 18           */ IL_000F: ldc.i4.2
		/* 0x00000261 8001000004   */ IL_0010: stsfld    int32 Program::result
		/* (13,9)-(13,25) C:\Users\andy\source\repos\ConsoleApp1\ConsoleApp1\Program.cs */
		/* 0x00000266 17           */ IL_0015: ldc.i4.1
		/* 0x00000267 8002000004   */ IL_0016: stsfld    bool Program::finished
		/* (14,5)-(14,6) C:\Users\andy\source\repos\ConsoleApp1\ConsoleApp1\Program.cs */
		/* 0x0000026C 2A           */ IL_001B: ret
	} // end of method Program::Thread2

	// Token: 0x06000002 RID: 2 RVA: 0x0000206D File Offset: 0x0000026D
	.method private hidebysig static 
		void Main () cil managed 
	{
		// Header Size: 1 byte
		// Code Size: 50 (0x32) bytes
		.maxstack 8
		.entrypoint

		/* (19,9)-(19,54) C:\Users\andy\source\repos\ConsoleApp1\ConsoleApp1\Program.cs */
		/* 0x0000026E 14           */ IL_0000: ldnull
		/* 0x0000026F FE0601000006 */ IL_0001: ldftn     void Program::Thread2()
		/* 0x00000275 730D00000A   */ IL_0007: newobj    instance void [System.Threading.Thread]System.Threading.ThreadStart::.ctor(object, native int)
		/* 0x0000027A 730E00000A   */ IL_000C: newobj    instance void [System.Threading.Thread]System.Threading.Thread::.ctor(class [System.Threading.Thread]System.Threading.ThreadStart)
		/* 0x0000027F 280F00000A   */ IL_0011: call      instance void [System.Threading.Thread]System.Threading.Thread::Start()
		// loop start (head: IL_0016)
			/* (24,13)-(24,26) C:\Users\andy\source\repos\ConsoleApp1\ConsoleApp1\Program.cs */
			/* 0x00000284 7E02000004   */ IL_0016: ldsfld    bool Program::finished
			/* 0x00000289 2CF9         */ IL_001B: brfalse.s IL_0016
		// end loop
		/* (26,17)-(26,59) C:\Users\andy\source\repos\ConsoleApp1\ConsoleApp1\Program.cs */
		/* 0x0000028B 7201000070   */ IL_001D: ldstr     "result = {0}"
		/* 0x00000290 7E01000004   */ IL_0022: ldsfld    int32 Program::result
		/* 0x00000295 8C10000001   */ IL_0027: box       [System.Runtime]System.Int32
		/* 0x0000029A 281000000A   */ IL_002C: call      void [System.Console]System.Console::WriteLine(string, object)
		/* (27,17)-(27,24) C:\Users\andy\source\repos\ConsoleApp1\ConsoleApp1\Program.cs */
		/* 0x0000029F 2A           */ IL_0031: ret
	} // end of method Program::Main

2.使用windbg查看汇编代码

用 !name2ee 查看 Main 方法描述符

0:008> !name2ee ConsoleApp1!Program.Main
Module:      00007ffb783568d8
Assembly:    ConsoleApp1.dll
Token:       0000000006000002
MethodDesc:  00007ffb78358850
Name:        Program.Main()
JITTED Code Address: 00007ffb78292da0

能看到 JITTED Code Address: 00007ffb78292da0 说明Main方法已经被 JIT 编译过了。

用户!U 查看汇编代码

0:008> !U 00007ffb78292da0
Normal JIT generated code
Program.Main()
ilAddr is 000002002D29206D pImport is 00000277670FDD20
Begin 00007FFB78292DA0, size c5

C:\Users\andy\source\repos\ConsoleApp1\ConsoleApp1\Program.cs @ 18:
>>> 00007ffb`78292da0 57              push    rdi
00007ffb`78292da1 56              push    rsi
00007ffb`78292da2 4883ec28        sub     rsp,28h
00007ffb`78292da6 48b9603c3778fb7f0000 mov rcx,7FFB78373C60h (MT: System.Threading.ThreadStart)
00007ffb`78292db0 e81b85bb5f      call    coreclr!JIT_TrialAllocSFastMP_InlineGetThread (00007ffb`d7e4b2d0)
00007ffb`78292db5 488bf0          mov     rsi,rax
00007ffb`78292db8 488d4e08        lea     rcx,[rsi+8]
00007ffb`78292dbc 488bd6          mov     rdx,rsi
00007ffb`78292dbf e8ec80bb5f      call    coreclr!JIT_WriteBarrier (00007ffb`d7e4aeb0)
00007ffb`78292dc4 48b940d01678fb7f0000 mov rcx,7FFB7816D040h
00007ffb`78292dce 48894e18        mov     qword ptr [rsi+18h],rcx
00007ffb`78292dd2 48b920f12878fb7f0000 mov rcx,7FFB7828F120h
00007ffb`78292ddc 48894e20        mov     qword ptr [rsi+20h],rcx
00007ffb`78292de0 48b938053378fb7f0000 mov rcx,7FFB78330538h (MT: System.Threading.Thread)
00007ffb`78292dea e8616da85f      call    coreclr!JIT_New (00007ffb`d7d19b50)
00007ffb`78292def 488bf8          mov     rdi,rax
00007ffb`78292df2 488bcf          mov     rcx,rdi
00007ffb`78292df5 488bd6          mov     rdx,rsi
00007ffb`78292df8 e80b68ffff      call    00007ffb`78289608
00007ffb`78292dfd 488b7720        mov     rsi,qword ptr [rdi+20h]
00007ffb`78292e01 4885f6          test    rsi,rsi
00007ffb`78292e04 7417            je      00007ffb`78292e1d
00007ffb`78292e06 33c0            xor     eax,eax
00007ffb`78292e08 48894610        mov     qword ptr [rsi+10h],rax
00007ffb`78292e0c e88ffbffff      call    00007ffb`782929a0
00007ffb`78292e11 488d4e28        lea     rcx,[rsi+28h]
00007ffb`78292e15 488bd0          mov     rdx,rax
00007ffb`78292e18 e89380bb5f      call    coreclr!JIT_WriteBarrier (00007ffb`d7e4aeb0)
00007ffb`78292e1d 488bcf          mov     rcx,rdi
00007ffb`78292e20 e89b66ffff      call    00007ffb`782894c0
00007ffb`78292e25 0fb60dd43e0c00  movzx   ecx,byte ptr [00007ffb`78356d00]

C:\Users\andy\source\repos\ConsoleApp1\ConsoleApp1\Program.cs @ 22:
00007ffb`78292e2c 85c9            test    ecx,ecx
00007ffb`78292e2e 74fc            je      00007ffb`78292e2c

C:\Users\andy\source\repos\ConsoleApp1\ConsoleApp1\Program.cs @ 24:
00007ffb`78292e30 48b980943178fb7f0000 mov rcx,7FFB78319480h (MT: System.Int32)
00007ffb`78292e3a e89184bb5f      call    coreclr!JIT_TrialAllocSFastMP_InlineGetThread (00007ffb`d7e4b2d0)
00007ffb`78292e3f 8b15b73e0c00    mov     edx,dword ptr [00007ffb`78356cfc]
00007ffb`78292e45 895008          mov     dword ptr [rax+8],edx
00007ffb`78292e48 488bd0          mov     rdx,rax
00007ffb`78292e4b 48b9d830344500020000 mov rcx,200453430D8h
00007ffb`78292e55 488b09          mov     rcx,qword ptr [rcx]
00007ffb`78292e58 e823f7ffff      call    00007ffb`78292580 (System.Console.WriteLine(System.String, System.Object), mdToken: 0000000006000081)

C:\Users\andy\source\repos\ConsoleApp1\ConsoleApp1\Program.cs @ 25:
00007ffb`78292e5d 90              nop
00007ffb`78292e5e 4883c428        add     rsp,28h
00007ffb`78292e62 5e              pop     rsi
00007ffb`78292e63 5f              pop     rdi
00007ffb`78292e64 c3              ret

我们重点看一下第22行源代码,对照来看,if(finished) 判断条件已经转换为 test 条件判断指令,并且取值是来自于ecx 寄存器。
在这里插入图片描述

使用r 查看寄存器的值,发现值为0,所以这个判断条件一直不成立,程序就会一直死循环运行下去。

0:008> r ecx
ecx=0

我们可以再验证一下finished字段的值,使用!name2ee 查看Program的EEClass。

0:008> !name2ee ConsoleApp1!Program
Module:      00007ffb783968d8
Assembly:    ConsoleApp1.dll
Token:       0000000002000002
MethodTable: 00007ffb78398878
EEClass:     00007ffb7838d508
Name:        Program

使用!DumpClass 查看静态变量,发现finished值已经是1了,和寄存器ecx不一致。

0:008> !DumpClass 00007ffb7838d508
Class Name:      Program
mdToken:         0000000002000002
File:            C:\Users\Andy\Desktop\Release\Release\net6.0\ConsoleApp1.dll
Parent Class:    00007ffb782ab8b8
Module:          00007ffb783968d8
Method Table:    00007ffb78398878
Vtable Slots:    4
Total Method Slots:  5
Class Attributes:    100000  
NumInstanceFields:   0
NumStaticFields:     2
              MT    Field   Offset                 Type VT     Attr            Value Name
00007ffb78359480  4000001       3c         System.Int32  1   static                2 result
00007ffb782bbf28  4000002       40       System.Boolean  1   static                1 finished

分析到这里,问题就比较清晰了,在Release模式下,由于JIT编译器做了代码优化,将finished的值直接放在了ecx寄存器中, 当另一个线程修改finished值后,exc寄存器中的值并没有跟着改变,主线程一直读ecx寄存器的值0,不符合退出条件,导致程序死循环运行。

三、解决方案

通过上边的分析,我们了解到是因为编译器的优化,将变量的值直接存放到了寄存器中,并且外部修改变量后未同步修改寄存器中的值,导致缓存与实际的数据不一致。所以,我们需要一种方案来避免这种优化,保证数据的一致性。

1.volatile

volatile的字面意思就是“易失的”或“易变的”,它提醒编译器,volatile修饰的变量随时都有可能改变,因此编译后的程序每次需要存储或读取这个变量的时候,告诉编译器对该变量不做优化,都会直接从变量内存地址中读取数据。

我们将finished变量增加volatile修饰后,再来看一下汇编代码。此时条件判断换成了cmp指令,它跟test指令差不多,重点在于取值已经改为取偏移地址上的值了,也就是说不再是取寄存器,而且直接从变量内存地址中读取数据。

public static volatile bool finished;
C:\Users\andy\source\repos\ConsoleApp1\ConsoleApp1\Program.cs @ 22:
00007ffb`77f32dc5 803d343f0c0000  cmp     byte ptr [00007ffb`77ff6d00],0
00007ffb`77f32dcc 74f7            je      00007ffb`77f32dc5

2.Thread.MemoryBarrier()

MemoryBarrier的字面意思是“内存屏障”,在读取变量前或后,调用Thread.MemoryBarrier()方法,可以显式的创建内存屏障,来对阻止编译器优化,避免读写缓存产生的影响。

如下修改代码后,Release模式下程序可以正常结束。

        for (; ; )
        {
            Thread.MemoryBarrier();

            if (finished)
            {
                Console.WriteLine("result = {0}", result);
                return;
            }
        }

在这里插入图片描述

总结

编译器为了提升程序性能,往往会做出比较大胆的优化,将共享变量的值直接做为常量存储到了寄存器中,或许它只考虑了在单线程环境下这种优化不会出现问题。但在多线程环境下,事情就会变的复杂,开发者一定要了解这些情况,才能对症下药,写出高质量的代码。

  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值