C# AsyncLocal 是如何實(shí)現(xiàn) Thread 間傳值
一:背景
1. 講故事
這個(gè)問(wèn)題的由來(lái)是在.NET高級(jí)調(diào)試訓(xùn)練營(yíng)第十期分享ThreadStatic底層玩法的時(shí)候,有朋友提出了AsyncLocal是如何實(shí)現(xiàn)的,雖然做了口頭上的表述,但總還是會(huì)不具體,所以覺(jué)得有必要用文字+圖表的方式來(lái)系統(tǒng)的說(shuō)一下這個(gè)問(wèn)題。
二:AsyncLocal 線程間傳值
1. 線程間傳值途徑
在 C# 編程中實(shí)現(xiàn)多線程以及線程切換的方式大概如下三種:
- Thread
- Task
- await,async
這三種場(chǎng)景下的線程間傳值有各自的實(shí)現(xiàn)方式,由于篇幅限制,先從 Thread 開(kāi)始聊吧。本質(zhì)上來(lái)說(shuō) AsyncLocal 是一個(gè)純托管的C#玩法,和 coreclr,Windows 沒(méi)有任何關(guān)系。
2. Thread 小例子
為了方便講述,先來(lái)一個(gè)例子看下如何在新Thread線程中提取 _asyncLocal 中的值,參考代碼如下:
internal class Program
{
static AsyncLocal<int> _asyncLocal = new AsyncLocal<int>();
static void Main(string[] args)
{
_asyncLocal.Value = 10;
var t = new Thread(() =>
{
Console.WriteLine($"Tid={Thread.CurrentThread.ManagedThreadId}, AsyncLocal value: {_asyncLocal.Value},");
Debugger.Break();
});
t.Start();
Console.ReadLine();
}
}
圖片
從截圖看 tid=7 線程果然拿到了 主線程設(shè)置的 10 ,哈哈,是不是充滿了好奇心?接下來(lái)逐一分析下吧。
3. 流轉(zhuǎn)分析
首先觀察下 _asyncLocal.Value = 10 在源碼層做了什么,參考代碼如下:
public T Value
{
set
{
ExecutionContext.SetLocalValue(this, value, m_valueChangedHandler != null);
}
}
internal static void SetLocalValue(IAsyncLocal local, object newValue, bool needChangeNotifications)
{
ExecutionContext executionContext = Thread.CurrentThread._executionContext;
Thread.CurrentThread._executionContext = new ExecutionContext(asyncLocalValueMap, array, flag2));
}
從源碼中可以看到這個(gè) 10 最終封印在 Thread.CurrentThread._executionContext 字段中,接下來(lái)就是核心問(wèn)題了,它是如何被送到新線程中的呢?
其實(shí)仔細(xì)想一想,要讓我實(shí)現(xiàn)的話,我肯定這么實(shí)現(xiàn)。
- 將主線程的 _executionContext 字段賦值給新線程 t._executionContext 字段。
- 將 var t = new Thread() 中的t作為參數(shù)傳遞給 win32 的 CreateThread 函數(shù),這樣在新線程中就可以提取 到 t 了,然后執(zhí)行 t 的callback。
這么說(shuō)大家可能有點(diǎn)抽象,我就直接畫下C#是怎么流轉(zhuǎn)的圖吧:
圖片
有了這張圖之后接下來(lái)的問(wèn)題就是驗(yàn)證了,首先看一下 copy 操作在哪里?可以觀察下 Start 源碼。
private void Start(bool captureContext)
{
StartHelper startHelper = _startHelper;
if (startHelper != null)
{
startHelper._startArg = null;
startHelper._executionContext = (captureContext ? System.Threading.ExecutionContext.Capture() : null);
}
StartCore();
}
public static ExecutionContext? Capture()
{
ExecutionContext executionContext = Thread.CurrentThread._executionContext;
return executionContext;
}
從源碼中可以看到將主線程的 _executionContext 字段給了新線程t下的startHelper._executionContext 。
接下來(lái)我們觀察下在創(chuàng)建 OS 線程的時(shí)候是不是將 Thread 作為參數(shù)傳過(guò)去了,如果傳過(guò)去了,那就可以直接在新線程中拿到 Thread._startHelper._executionContext 字段,驗(yàn)證起來(lái)也很簡(jiǎn)單,在win32 的 ntdll!NtCreateThreadEx 上下一個(gè)斷點(diǎn)即可。
0:000> bp ntdll!NtCreateThreadEx
0:000> g
Breakpoint 1 hit
ntdll!NtCreateThreadEx:
00007ff9`0fe8e8c0 4c8bd1 mov r10,rcx
0:000> r
rax=00007ff8b4a529d0 rbx=0000000000000000 rcx=0000008471b7df28
rdx=00000000001fffff rsi=0000027f2ca25b01 rdi=0000027f2ca25b60
rip=00007ff90fe8e8c0 rsp=0000008471b7de68 rbp=00007ff8b4a529d0
r8=0000000000000000 r9=ffffffffffffffff r10=0000027f2c8a0000
r11=0000008471b7de40 r12=0000008471b7e890 r13=0000008471b7e4f8
r14=ffffffffffffffff r15=0000000000010000
iopl=0 nv up ei pl nz na po nc
cs=0033 ss=002b ds=002b es=002b fs=0053 gs=002b efl=00000206
ntdll!NtCreateThreadEx:
00007ff9`0fe8e8c0 4c8bd1 mov r10,rcx
0:000> !t
ThreadCount: 4
UnstartedThread: 1
BackgroundThread: 2
PendingThread: 0
DeadThread: 0
Hosted Runtime: no
Lock
DBG ID OSID ThreadOBJ State GC Mode GC Alloc Context Domain Count Apt Exception
0 1 2cd8 0000027F2C9E6610 2a020 Preemptive 0000027F2E5DB438:0000027F2E5DB4A0 0000027f2c9dd670 -00001 MTA
6 2 2b24 0000027F2CA121E0 21220 Preemptive 0000000000000000:0000000000000000 0000027f2c9dd670 -00001 Ukn (Finalizer)
7 3 2658 0000027F4EAA0AE0 2b220 Preemptive 0000000000000000:0000000000000000 0000027f2c9dd670 -00001 MTA
XXXX 4 0 0000027F2CA25B60 9400 Preemptive 0000000000000000:0000000000000000 0000027f2c9dd670 -00001 Ukn
從輸出中可以看到 NtCreateThreadEx 方法的第二個(gè)參數(shù)即 rdi=0000027f2ca25b60 就是我們的托管線程,如果你不相信的話可以再用 windbg 找到它的托管線程信息,輸出如下:
0:000> dt coreclr!Thread 0000027F2CA25B60 -y m_ExposedObject
+0x1c8 m_ExposedObject : 0x0000027f`2c8f11d0 OBJECTHANDLE__
0:000> !do poi(0x0000027f`2c8f11d0)
Name: System.Threading.Thread
MethodTable: 00007ff855090d78
EEClass: 00007ff85506a700
Tracked Type: false
Size: 72(0x48) bytes
File: C:\Program Files\dotnet\shared\Microsoft.NETCore.App\6.0.25\System.Private.CoreLib.dll
Fields:
MT Field Offset Type VT Attr Value Name
00007ff8550c76d8 4000b35 8 ....ExecutionContext 0 instance 0000000000000000 _executionContext
0000000000000000 4000b36 10 ...ronizationContext 0 instance 0000000000000000 _synchronizationContext
00007ff85508d708 4000b37 18 System.String 0 instance 0000000000000000 _name
00007ff8550cb9d0 4000b38 20 ...hread+StartHelper 0 instance 0000027f2e5db3b0 _startHelper
...
有些朋友可能要說(shuō),你現(xiàn)在的 _executionContext 字段是保留在 _startHelper 類里,并沒(méi)有賦值到Thread._executionContext字段呀?那這一塊在哪里實(shí)現(xiàn)的呢?從上圖可以看到其實(shí)是在新線程的執(zhí)行函數(shù)上,在托管函數(shù)執(zhí)行之前會(huì)將 _startHelper._executionContext 賦值給 Thread._executionContext , 讓 windbg 繼續(xù)執(zhí)行,輸出如下:
0:009> k
# Child-SP RetAddr Call Site
00 00000084`728ff778 00007ff8`b4c23d19 KERNELBASE!wil::details::DebugBreak+0x2
01 00000084`728ff780 00007ff8`b43ba7ea coreclr!DebugDebugger::Break+0x149 [D:\a\_work\1\s\src\coreclr\vm\debugdebugger.cpp @ 148]
02 00000084`728ff900 00007ff8`54ff56e3 System_Private_CoreLib!System.Diagnostics.Debugger.Break+0xa [/_/src/coreclr/System.Private.CoreLib/src/System/Diagnostics/Debugger.cs @ 18]
03 00000084`728ff930 00007ff8`b42b4259 ConsoleApp9!ConsoleApp9.Program.<>c.<Main>b__1_0+0x113
04 00000084`728ff9c0 00007ff8`b42bddd9 System_Private_CoreLib!System.Threading.Thread.StartHelper.Callback+0x39 [/_/src/libraries/System.Private.CoreLib/src/System/Threading/Thread.cs @ 42]
05 00000084`728ffa00 00007ff8`b42b2f4a System_Private_CoreLib!System.Threading.ExecutionContext.RunInternal+0x69 [/_/src/libraries/System.Private.CoreLib/src/System/Threading/ExecutionContext.cs @ 183]
06 00000084`728ffa70 00007ff8`b4b7ba53 System_Private_CoreLib!System.Threading.Thread.StartCallback+0x8a [/_/src/coreclr/System.Private.CoreLib/src/System/Threading/Thread.CoreCLR.cs @ 105]
07 00000084`728ffab0 00007ff8`b4a763dc coreclr!CallDescrWorkerInternal+0x83
08 00000084`728ffaf0 00007ff8`b4b5e713 coreclr!DispatchCallSimple+0x80 [D:\a\_work\1\s\src\coreclr\vm\callhelpers.cpp @ 220]
09 00000084`728ffb80 00007ff8`b4a52d25 coreclr!ThreadNative::KickOffThread_Worker+0x63 [D:\a\_work\1\s\src\coreclr\vm\comsynchronizable.cpp @ 158]
...
0d (Inline Function) --------`-------- coreclr!ManagedThreadBase_FullTransition+0x2d [D:\a\_work\1\s\src\coreclr\vm\threads.cpp @ 7569]
0e (Inline Function) --------`-------- coreclr!ManagedThreadBase::KickOff+0x2d [D:\a\_work\1\s\src\coreclr\vm\threads.cpp @ 7604]
0f 00000084`728ffd60 00007ff9`0e777614 coreclr!ThreadNative::KickOffThread+0x79 [D:\a\_work\1\s\src\coreclr\vm\comsynchronizable.cpp @ 230]
10 00000084`728ffdc0 00007ff9`0fe426a1 KERNEL32!BaseThreadInitThunk+0x14
11 00000084`728ffdf0 00000000`00000000 ntdll!RtlUserThreadStart+0x21
...
在上面的回調(diào)函數(shù)中看的非常清楚,在執(zhí)行托管函數(shù) <Main>b__1_0 之前執(zhí)行了一個(gè) ExecutionContext.RunInternal 函數(shù),對(duì),就是它來(lái)實(shí)現(xiàn)的,參考代碼如下:
private sealed class StartHelper
{
internal void Run()
{
System.Threading.ExecutionContext.RunInternal(_executionContext, s_threadStartContextCallback, this);
}
}
internal static void RunInternal(ExecutionContext executionContext, ContextCallback callback, object state)
{
Thread currentThread = Thread.CurrentThread;
RestoreChangedContextToThread(currentThread, executionContext, executionContext3);
}
internal static void RestoreChangedContextToThread(Thread currentThread, ExecutionContext contextToRestore, ExecutionContext currentContext)
{
currentThread._executionContext = contextToRestore;
}
既然將 StartHelper.executionContext 塞到了 currentThread._executionContext 中,在 <Main>b__1_0 方法中自然就能通過(guò) _asyncLocal.Value 提取了。
三:總結(jié)
說(shuō)了這么多,其實(shí)精妙之處在于創(chuàng)建OS線程的時(shí)候,會(huì)把C# Thread實(shí)例(coreclr對(duì)應(yīng)線程) 作為參數(shù)傳遞給新線程,即下面方法簽名中的 lpParameter 參數(shù),新線程拿到了Thread實(shí)例,自然就能獲取到被調(diào)用線程賦值的 Thread._executionContext 字段,所以這是完完全全的C#層面玩法,希望能給后來(lái)者解惑吧!
HANDLE CreateThread(
[in, optional] LPSECURITY_ATTRIBUTES lpThreadAttributes,
[in] SIZE_T dwStackSize,
[in] LPTHREAD_START_ROUTINE lpStartAddress,
[in, optional] __drv_aliasesMem LPVOID lpParameter,
[in] DWORD dwCreationFlags,
[out, optional] LPDWORD lpThreadId
);