文章目录
前言
之前写过一篇多线程并发编程的文章,讲的是如何用“锁”来解决线程安全问题及“锁”的原理。感兴趣的同学可以点击多线程并发编程“锁”事再回顾一下。多线程并发编程其实还有很多东西可讲,所以我觉得可以写一系列文章,将多线程并发编程掰开来揉碎了,讲述其中的原理,指出其中的坑,总结正确的用法等,让大家在实际的开发中受益。本章我们讲一个多线程并发编程中的“怪”事。
一、奇怪,编译模式竟然会影响程序执行结果
先来看下面一段代码,实现的逻辑大概是一个线程在等待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编译模式后,程序总是不能结束运行。
我们知道,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;
}
}
总结
编译器为了提升程序性能,往往会做出比较大胆的优化,将共享变量的值直接做为常量存储到了寄存器中,或许它只考虑了在单线程环境下这种优化不会出现问题。但在多线程环境下,事情就会变的复杂,开发者一定要了解这些情况,才能对症下药,写出高质量的代码。