一:背景
1. 講故事
有朋友在微信裏面問我,爲什麼用 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++ ,相信你會陷入到很多的魔法使用中而苦於不能獨自解惑的遺憾。