浅析C#线程本地存储中为什么线程间值不一样
作者:一线码农
一:背景
讲故事
有朋友在微信里面问我,为什么用 ThreadStatic
标记的字段,只有第一个线程拿到了初始值,其他线程都是默认值,让我能不能帮他解答一下,尼玛,我也不是神仙什么都懂,既然问了,那我试着帮他解答一下,也给后面类似疑问的朋友解个惑吧。
二:为什么值不一样
1. 问题复现
为了方便讲述,定义一个 ThreadStatic 的变量,然后用多个线程去访问,参考代码如下:
internal class Program { [ThreadStatic] public static int num = 10; static void Main(string[] args) { Test(); Console.ReadLine(); } /// <summary> /// 1. 特性方式 /// </summary> static void Test() { var t1 = new Thread(() => { Debugger.Break(); var j = num; Console.WriteLine($"tid={Thread.CurrentThread.ManagedThreadId}, num={j}"); }); t1.Start(); t1.Join(); var t2 = new Thread(() => { Debugger.Break(); var j = num; Console.WriteLine($"tid={Thread.CurrentThread.ManagedThreadId}, num={j}"); }); t2.Start(); } }
从代码中可以看到,确实如朋友所说,一个是num=10
,一个是num=0
,那为什么会出现这样的情况呢?
2. 从汇编上寻找答案
作为C#程序员,真的需要掌握一点汇编,往往就能找到问题的突破口,先看一下thread1 中的 var j = num;
所对应的汇编代码,参考如下:
D:\code\MyApplication\ConsoleApp7\Program.cs @ 27:
08893737 b9a0dd6808 mov ecx,868DDA0h
0889373c ba04000000 mov edx,4
08893741 e84a234e71 call coreclr!JIT_GetSharedNonGCThreadStaticBase (79d75a90)
08893746 8b4814 mov ecx,dword ptr [eax+14h]
08893749 894df8 mov dword ptr [ebp-8],ecx
从汇编上可以看到,这个 num=10 是来自于 eax+14h
的地址上,而 eax 是 JIT_GetSharedNonGCThreadStaticBase 函数的返回值,言外之意核心逻辑是在此方法里,可以到 coreclr 中找一下这段代码,简化后如下:
HCIMPL2(void*, JIT_GetSharedNonGCThreadStaticBase, DomainLocalModule *pDomainLocalModule, DWORD dwClassDomainID) { FCALL_CONTRACT; // Get the ModuleIndex ModuleIndex index = pDomainLocalModule->GetModuleIndex(); // Get the relevant ThreadLocalModule ThreadLocalModule * pThreadLocalModule = ThreadStatics::GetTLMIfExists(index); // If the TLM has been allocated and the class has been marked as initialized, // get the pointer to the non-GC statics base and return if (pThreadLocalModule != NULL && pThreadLocalModule->IsPrecomputedClassInitialized(dwClassDomainID)) return (void*)pThreadLocalModule->GetPrecomputedNonGCStaticsBasePointer(); // If the TLM was not allocated or if the class was not marked as initialized // then we have to go through the slow path // Obtain the MethodTable MethodTable * pMT = pDomainLocalModule->GetMethodTableFromClassDomainID(dwClassDomainID); return HCCALL1(JIT_GetNonGCThreadStaticBase_Helper, pMT); }
这段代码非常有意思,已经把 ThreadStatic
玩法的骨架图给绘制出来了,大概意思是每个线程都有一个 ThreadLocalBlock
结构体,这个结构体下有一个 ThreadLocalModule
的字典,key 为 ModuleIndex, value 为 ThreadLocalModule,画个简图如下:
从图中可以看到 num 是放在 ThreadLocalModule 中的,具体的说就是此结构的 m_pDataBlob
数组中,可以用 windbg 验证下。
0:008> r
eax=03077810 ebx=08baf978 ecx=79d75c10 edx=03110568 esi=053faa18 edi=053fa9b8
eip=08893746 esp=08baf8d8 ebp=08baf908 iopl=0 nv up ei pl zr na pe nc
cs=0023 ss=002b ds=002b es=002b fs=0053 gs=002b efl=00000246
ConsoleApp7!ConsoleApp7.Program.<>c.<Test>b__2_0+0x46:
08893746 8b4814 mov ecx,dword ptr [eax+14h] ds:002b:03077824=0000000a
0:008> dt coreclr!ThreadLocalModule 03077810
+0x000 m_pDynamicClassTable : (null)
+0x004 m_aDynamicEntries : 0
+0x008 m_pGCStatics : (null)
+0x00c m_pDataBlob : [0] ""
0:008> dp 03077810+0x14 L1
03077824 0000000a
有了这些前置知识后,接下来就简单了,如果当前的 ThreadLocalModule 不存在就会调用 JIT_GetNonGCThreadStaticBase_Helper 函数在 m_pTLMTable 字段中添加一项,接下来观察下这个函数代码,简化如下:
HCIMPL1(void*, JIT_GetNonGCThreadStaticBase_Helper, MethodTable * pMT) { // Get the TLM ThreadLocalModule * pThreadLocalModule = ThreadStatics::GetTLM(pMT); // Check if the class constructor needs to be run pThreadLocalModule->CheckRunClassInitThrowing(pMT); // Lookup the non-GC statics base pointer base = (void*) pMT->GetNonGCThreadStaticsBasePointer(); return base; } PTR_ThreadLocalModule ThreadStatics::GetTLM(ModuleIndex index, Module * pModule) //static { // Get the TLM if it already exists PTR_ThreadLocalModule pThreadLocalModule = ThreadStatics::GetTLMIfExists(index); // If the TLM does not exist, create it now if (pThreadLocalModule == NULL) { // Allocate and initialize the TLM, and add it to the TLB's table pThreadLocalModule = AllocateAndInitTLM(index, pThreadLocalBlock, pModule); } return pThreadLocalModule; }
上面这段代码的步骤很清楚。
- 创建 ThreadLocalModule
- 初始化 MethodTable 类型的字段 pMT
这个 pMT 非常重要,训练营里的朋友都知道 MethodTable 是 C# 的 class 承载,言外之意就是判断下这个 class 有没有被初始化,如果没有初始化那就调 静态构造函数
,接下来的问题是 class 到底是哪一个类呢?
结合刚才汇编中的 mov edx,4
以及源码发现是取 IL 元数据中的 Program,参考代码及截图如下:
FORCEINLINE MethodTable * GetMethodTableFromClassDomainID(DWORD dwClassDomainID) { DWORD rid = (DWORD)(dwClassDomainID) + 1; TypeHandle th = GetDomainFile()->GetModule()->LookupTypeDef(TokenFromRid(rid, mdtTypeDef)); MethodTable * pMT = th.AsMethodTable(); return pMT; }
也可以用 windbg 在 JIT_GetNonGCThreadStaticBase_Helper 方法的 return 处下一个断点,参考如下:
0:008> r ecx
ecx=0564ef28
0:008> !dumpmt 0564ef28
EEClass: 056d14d0
Module: 0564db08
Name: ConsoleApp7.Program
mdToken: 02000005
File: D:\code\MyApplication\ConsoleApp7\bin\x86\Debug\net6.0\ConsoleApp7.dll
AssemblyLoadContext: Default ALC - The managed instance of this context doesn't exist yet.
BaseSize: 0xc
ComponentSize: 0x0
DynamicStatics: false
ContainsPointers: false
Slots in VTable: 8
Number of IFaces in IFaceMap: 0
到这里就真相大白了,thread1 在执行时,用 CheckRunClassInitThrowing 方法发现 Program 没有被静态构造过,所以就执行了,即 num=10
,当 thread2 执行时,发现已经被构造过了,所以就不再执行静态构造函数,所以就成了默认值 num=0
。
3. 如何复验你的结论
刚才我说 thread1 做了一个是否执行静态构造的判断,其实这里我可以做个手脚,在 Main 之前先把 Program 静态函数给执行掉,按理说 thread1 和 thread2 此时都会是默认值 num=0
,对不对,哈哈,试一试呗,简化代码如下:
internal class Program { [ThreadStatic] public static int num = 10; /// <summary> /// 先于 main 执行 /// </summary> static Program() { } static void Main(string[] args) { Test(); Console.ReadLine(); } }
哈哈,此时都是 0 了,也就再次验证了我的结论。
以上就是浅析C#线程本地存储中为什么线程间值不一样的详细内容,更多关于C#线程本地存储的资料请关注脚本之家其它相关文章!