天天看點

C# 線程本地存儲 為什麼線程間值不一樣

作者:opendotnet

一:背景

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();
 }
}

           
C# 線程本地存儲 為什麼線程間值不一樣

從代碼中可以看到,确實如朋友所說,一個是

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 != && 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,畫個簡圖如下:

C# 線程本地存儲 為什麼線程間值不一樣

從圖中可以看到 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 : () 
 +0x004 m_aDynamicEntries : 0
 +0x008 m_pGCStatics : () 
 +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 == )
 {
// 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;
 }

           
C# 線程本地存儲 為什麼線程間值不一樣

也可以用 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();
 }
 }

           
C# 線程本地存儲 為什麼線程間值不一樣

哈哈,此時都是 0 了,也就再次驗證了我的結論。

三:總結

在 C# 開發中經常會有一些疑惑,如果不了解彙編,C++ ,相信你會陷入到很多的魔法使用中而苦于不能獨自解惑的遺憾。

繼續閱讀