當前位置: 妍妍網 > 碼農

C# AsyncLocal 是如何實作 Thread 間傳值

2024-01-26碼農

一:背景

1. 講故事

這個問題的由來是在 .NET高級偵錯訓練營第十期 分享ThreadStatic底層玩法的時候,有朋友提出了 AsyncLocal 是如何實作的,雖然做了口頭上的表述,但總還是會不具體,所以覺得有必要用 文字+圖表 的方式來系統的說一下這個問題。

二:AsyncLocal 執行緒間傳值

1. 執行緒間傳值途徑

在 C# 編程中實作多執行緒以及執行緒切換的方式大概如下三種:

  • Thread

  • Task

  • await,async

  • 這三種場景下的執行緒間傳值有各自的實作方式,由於篇幅限制,先從 Thread 開始聊吧。本質上來說 AsyncLocal 是一個純托管的C#玩法,和 coreclr,Windows 沒有任何關系。

    2. Thread 小例子

    為了方便講述,先來一個例子看下如何在新Thread執行緒中提取 _asyncLocal 中的值,參考程式碼如下:


    internal classProgram
    {
    static AsyncLocal<int> _asyncLocal = new AsyncLocal<int>();
    staticvoidMain(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 執行緒果然拿到了 主執行緒設定的 10 ,哈哈,是不是充滿了好奇心?接下來逐一分析下吧。

    3. 流轉分析

    首先觀察下 _asyncLocal.Value = 10 在源碼層做了什麽,參考程式碼如下:


    public T Value
    {
    set
    {
    ExecutionContext.SetLocalValue(thisvalue, m_valueChangedHandler != null);
    }
    }
    internalstaticvoidSetLocalValue(IAsyncLocal local, object newValue, bool needChangeNotifications)
    {
    ExecutionContext executionContext = Thread.CurrentThread._executionContext;
    Thread.CurrentThread._executionContext = new ExecutionContext(asyncLocalValueMap, array, flag2));
    }

    從源碼中可以看到這個 10 最終封印在 Thread.CurrentThread._executionContext 欄位中,接下來就是核心問題了,它是如何被送到新執行緒中的呢?

    其實仔細想一想,要讓我實作的話,我肯定這麽實作。

    1. 將主執行緒的 _executionContext 欄位賦值給新執行緒 t._executionContext 欄位。

    2. var t = new Thread() 中的t作為參數傳遞給 win32 的 CreateThread 函式,這樣在新執行緒中就可以提取 到 t 了,然後執行 t 的callback。

    這麽說大家可能有點抽象,我就直接畫下C#是怎麽流轉的圖吧:

    有了這張圖之後接下來的問題就是驗證了,首先看一下 copy 操作在哪裏?可以觀察下 Start 源碼。


    privatevoidStart(bool captureContext)
    {
    StartHelper startHelper = _startHelper;
    if (startHelper != null)
    {
    startHelper._startArg = null;
    startHelper._executionContext = (captureContext ? System.Threading.ExecutionContext.Capture() : null);
    }
    StartCore();
    }
    publicstatic ExecutionContext? Capture()
    {
    ExecutionContext executionContext = Thread.CurrentThread._executionContext;
    return executionContext;
    }

    從源碼中可以看到將主執行緒的 _executionContext 欄位給了新執行緒t下的 startHelper._executionContext

    接下來我們觀察下在建立 OS 執行緒的時候是不是將 Thread 作為參數傳過去了,如果傳過去了,那就可以直接在新執行緒中拿到 Thread._startHelper._executionContext 欄位,驗證起來也很簡單,在win32 的 ntdll!NtCreateThreadEx 上下一個斷點即可。


    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
    012cd8 0000027F2C9E6610 2a020 Preemptive 0000027F2E5DB438:0000027F2E5DB4A0 0000027f2c9dd670 -00001 MTA 
    622b24 0000027F2CA121E0 21220 Preemptive 0000000000000000:00000000000000000000027f2c9dd670 -00001 Ukn (Finalizer) 
    7326580000027F4EAA0AE0 2b220 Preemptive 0000000000000000:00000000000000000000027f2c9dd670 -00001 MTA 
    XXXX 400000027F2CA25B60 9400 Preemptive 0000000000000000:00000000000000000000027f2c9dd670 -00001 Ukn 

    從輸出中可以看到 NtCreateThreadEx 方法的第二個參數即 rdi=0000027f2ca25b60 就是我們的托管執行緒,如果你不相信的話可以再用 windbg 找到它的托管執行緒資訊,輸出如下:


    0:000> dt coreclr!Thread 0000027F2CA25B60 -y m_ExposedObject
    +0x1c8 m_ExposedObject : 0x0000027f`2c8f11d0 OBJECTHANDLE__
    0:000> !dopoi(0x0000027f`2c8f11d0)
    Name: System.Threading.Thread
    MethodTable: 00007ff855090d78
    EE class: 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
    ...

    有些朋友可能要說,你現在的 _executionContext 欄位是保留在 _startHelper 類裏,並沒有賦值到Thread._executionContext欄位呀?那這一塊在哪裏實作的呢?從上圖可以看到其實是在新執行緒的執行函式上,在托管函式執行之前會將 _startHelper._executionContext 賦值給 Thread._executionContext , 讓 windbg 繼續執行,輸出如下:


    0:009> k
    # Child-SP RetAddr Call Site
    0000000084`728ff778 00007ff8`b4c23d19 KERNELBASE!wil::details::DebugBreak+0x2
    0100000084`728ff780 00007ff8`b43ba7ea coreclr!DebugDebugger::Break+0x149 [D:\a\_work\1\s\src\coreclr\vm\debugdebugger.cpp @ 148
    0200000084`728ff900 00007ff8`54ff56e3 System_Private_CoreLib!System.Diagnostics.Debugger.Break+0xa [/_/src/coreclr/System.Private.CoreLib/src/System/Diagnostics/Debugger.cs @ 18
    0300000084`728ff930 00007ff8`b42b4259 ConsoleApp9!ConsoleApp9.Program.<>c.<Main>b__1_0+0x113
    0400000084`728ff9c0 00007ff8`b42bddd9 System_Private_CoreLib!System.Threading.Thread.StartHelper.Callback+0x39 [/_/src/libraries/System.Private.CoreLib/src/System/Threading/Thread.cs @ 42
    0500000084`728ffa00 00007ff8`b42b2f4a System_Private_CoreLib!System.Threading.ExecutionContext.RunInternal+0x69 [/_/src/libraries/System.Private.CoreLib/src/System/Threading/ExecutionContext.cs @ 183
    0600000084`728ffa70 00007ff8`b4b7ba53 System_Private_CoreLib!System.Threading.Thread.StartCallback+0x8a [/_/src/coreclr/System.Private.CoreLib/src/System/Threading/Thread.CoreCLR.cs @ 105
    0700000084`728ffab0 00007ff8`b4a763dc coreclr!CallDescrWorkerInternal+0x83
    0800000084`728ffaf0 00007ff8`b4b5e713 coreclr!DispatchCallSimple+0x80 [D:\a\_work\1\s\src\coreclr\vm\callhelpers.cpp @ 220
    0900000084`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
    0f00000084`728ffd60 00007ff9`0e777614 coreclr!ThreadNative::KickOffThread+0x79 [D:\a\_work\1\s\src\coreclr\vm\comsynchronizable.cpp @ 230
    1000000084`728ffdc0 00007ff9`0fe426a1 KERNEL32!BaseThreadInitThunk+0x14
    1100000084`728ffdf0 00000000`00000000 ntdll!RtlUserThreadStart+0x21
    ...

    在上面的回呼函式中看的非常清楚,在執行托管函式 <Main>b__1_0 之前執行了一個 ExecutionContext.RunInternal 函式,對,就是它來實作的,參考程式碼如下:


    privatesealed classStartHelper
    {
    internalvoidRun()
    {
    System.Threading.ExecutionContext.RunInternal(_executionContext, s_threadStartContextCallback, this);
    }
    }
    internalstaticvoidRunInternal(ExecutionContext executionContext, ContextCallback callback, object state)
    {
    Thread currentThread = Thread.CurrentThread;
    RestoreChangedContextToThread(currentThread, executionContext, executionContext3);
    }
    internalstaticvoidRestoreChangedContextToThread(Thread currentThread, ExecutionContext contextToRestore, ExecutionContext currentContext)
    {
    currentThread._executionContext = contextToRestore;
    }

    既然將 StartHelper.executionContext 塞到了 currentThread._executionContext 中,在 <Main>b__1_0 方法中自然就能透過 _asyncLocal.Value 提取了。

    三:總結

    說了這麽多,其實精妙之處在於建立OS執行緒的時候,會把C# Thread例項(coreclr對應執行緒) 作為參數傳遞給新執行緒,即下面方法簽名中的 lpParameter 參數,新執行緒拿到了Thread例項,自然就能獲取到被呼叫執行緒賦值的 Thread._executionContext 欄位,所以這是完完全全的C#層面玩法,希望能給後來者解惑吧!


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