淺談AsyncLocal,我們應該知道的那些事兒

前言

最近查看有關框架源碼,發現AsyncLocal這玩意水還挺深,於是花了一點功夫去研究,同時對比ThreadLocal說明二者區別以及在何時場景下使用AsyncLocal或ThreadLocal。ThreadLocal相信很多童鞋用過,但AsyncLocal具體使用包括我在內的一大部分童鞋應該完全沒怎麼使用過。

AsyncLocal和ThreadLocal區別 

AsyncLocal同樣出現在.NET Framework 4.6+(包括4.6),當然在.NET Core中沒有版本限制即CoreCLR,對此類官方所給的解釋是:將本地環境數據傳遞到異步控制流,例如異步方法。又例如緩存WCF通信通道,可以使用AsyncLocal而不是.NET Framework或CoreCLR所提供的ThreadLocal。官方概念解釋在我們初次聽來好像還是有點抽象,不打緊,接下來我們通過實際例子來進行詳細說明和解釋,首先我們先看如下例子,然後再分析二者和什麼有關係

private static readonly ThreadLocal<string> threadLocal = new ThreadLocal<string>();
        
private static readonly AsyncLocal<string> asyncLocal = new AsyncLocal<string>();

static async Task Main(string[] args)
{
    threadLocal.Value = "threadLocal";
    asyncLocal.Value = "asyncLocal";

    await Task.Yield();

    Console.WriteLine("After await: " + threadLocal.Value);

    Console.WriteLine("After await: " + asyncLocal.Value);

    Task.Run(() => Console.WriteLine("Inside child task: " + threadLocal.Value)).Wait();

    Task.Run(() => Console.WriteLine("Inside child task: " + asyncLocal.Value)).Wait();

    Console.ReadLine();
}

猜猜如上將會打印出什麼結果呢?

爲何ThreadLocal所打印的值爲空值呢?我們不是設置了值嗎?此時我們將要從執行環境開始說起。若完全理解ExecutionContext與SynchronizationContext二者概念和關係,理論上來講則可解答出上述問題,這裏我們簡單敘述下,更詳細介紹請查閱相關資料自行了解ExecutionContext俗稱“執行上下文”,也就是說和“環境”信息相關,這也就意味着它存儲着和我們當前程序所執行的環境相關的數據,這類環境信息數據存儲在ThreadStatic或ThreadLocal中,換句話說ThreadLocal和特定線程相關。上述我們討論的是相同環境或上下文中,若是不同上下文即不同線程中,那情況又該如何呢?在異步操作中,在某一個線程中啓動操作,但卻在另一線程中完成,此時我們將不能利用ThreadLocal來存儲數據,因線程切換所需存儲數據,我們可以稱之爲環境“流動”。對於邏輯控制流,我們期望的是執行環境相關數據能同控制流一起流動,以便能讓執行環境相關數據能從一個線程移動到另外一個線程,ExecutionContext的作用就在於此。而SynchronizationContext是一種抽象,比如Windows窗體則提供了WindowsFormSynchronizationContext上下文等等

SynchronizationContext作爲ExecutionContext執行環境的一部分

ExecutionContext是當前執行環境,而SynchronizationContext則是針對不同框架或UI的抽象

我們可通過SynchronizationContext.Current得到當前執行環境信息。到這裏想必我們已經明白基於特定線程的ThreadLocal在當前線程設置值後,但await卻不在當前線程,所以打印值爲空,若將上述第一個await去除,則可打印出設置值,而AsyncLocal卻是和執行環境相關,也就是說與線程和調用堆棧有關,並不針對特定線程,它是流動的。

AsyncLocal原理初步分析

首先我們通過一個簡單的例子來演示AsyncLocal類中值變化過程,我們能從表面上可得出的結論,然後最終結合源碼進行進一步分析

private static readonly AsyncLocal<string> asyncLocal = new AsyncLocal<string>();

static async Task Main(string[] args)
{
    asyncLocal.Value = "asyncLocal";

    Task.Run(() =>
    {
      asyncLocal.Value = "inside child task asyncLocal";

      Console.WriteLine($"Inside child task: {asyncLocal.Value}");

    }).Wait();

    Console.WriteLine($"after await:{asyncLocal.Value}");

    Console.ReadLine();
}

由上打印我們可看出,在Task方法內部將其值進行了修改並打印出修改過後的結果,在Task結束後,最終打印的卻是初始值。在Task方法內部修改其值,但在任務結束後仍爲初始值,這是一種“寫時複製”行爲,AsyncLocal內部做了兩步操作

進行AsyncLocal實例的拷貝副本,但這是淺複製行爲而非深複製

在設置新的值之前完成複製操作

接下來我們再通過一個層層調用例子並深入分析

private static readonly AsyncLocal<string> asyncLocal = new AsyncLocal<string>();

static async Task Main(string[] args)
{
    Demo1().GetAwaiter().GetResult();

    Console.ReadLine();
}

static async Task Demo1()
{
    await Demo2();
    Console.WriteLine($"inside the method of demo1:{asyncLocal.Value}");
}

static async Task Demo2()
{
    SetValue();
    Console.WriteLine($"inside the method of demo2:{asyncLocal.Value}");
}

static void SetValue()
{
    asyncLocal.Value = "initial value";
}

我們看到此時在Demo1方法內部打印值爲空,因爲在Demo2方法內部並未使用異步,所以能打印出所設置的值,這說明:每次進行實際的async/await後,都會啓動一個新的異步上下文,並且該上下文與父異步上下文完全隔離且獨立,換句話說,在異步方法內,可查詢自己所屬AsyncLocal<T>,以便能確保不會污染父異步上下文,因爲所做更改完全是針對當前異步上下文的本地內容。至於爲何在Demo1方法內部打印爲空,想必我們已經很清晰,當async方法返回時,返回的是父異步上下文,此時將看不到任何子異步上下文所執行的修改。

AsyncLocal原理源碼分析

我們來到AsyncLocal類,通過屬性Value設置值,內部通過調用ExecutionContext類中的SetLocalValue方法進行設置,源碼如下:

internal static void SetLocalValue(IAsyncLocal local, object? newValue, bool needChangeNotifications)
{
    ExecutionContext? current = Thread.CurrentThread._executionContext;

    object? previousValue = null;
    bool hadPreviousValue = false;
    if (current != null)
    {
        hadPreviousValue = current.m_localValues.TryGetValue(local, out previousValue);
    }

    if (previousValue == newValue)
    {
        return;
    }

    IAsyncLocal[]? newChangeNotifications = null;
    IAsyncLocalValueMap newValues;
    bool isFlowSuppressed = false;
    if (current != null)
    {
        isFlowSuppressed = current.m_isFlowSuppressed;
        newValues = current.m_localValues.Set(local, newValue, treatNullValueAsNonexistent: !needChangeNotifications);
        newChangeNotifications = current.m_localChangeNotifications;
    }
    else
    {
        newValues = AsyncLocalValueMap.Create(local, newValue, treatNullValueAsNonexistent: !needChangeNotifications);
    }

    if (needChangeNotifications)
    {
        if (hadPreviousValue)
        {
          Debug.Assert(newChangeNotifications != null);
          Debug.Assert(Array.IndexOf(newChangeNotifications, local) >= 0);
        }
        else if (newChangeNotifications == null)
        {
          newChangeNotifications = new IAsyncLocal[1] { local };
        }
        else
        {
          int newNotificationIndex = newChangeNotifications.Length;
          Array.Resize(ref newChangeNotifications, newNotificationIndex + 1);
          newChangeNotifications[newNotificationIndex] = local;
        }
    }

    Thread.CurrentThread._executionContext =
      (!isFlowSuppressed && AsyncLocalValueMap.IsEmpty(newValues)) ?
      null : 
      new ExecutionContext(newValues, newChangeNotifications, isFlowSuppressed);

    if (needChangeNotifications)
    {
        local.OnValueChanged(previousValue, newValue, contextChanged: false);
    }
}

當首次設置值時,我們通過Thread.CurrentThread.ExecutionContext,獲取其屬性將爲空,通過AsyncLocalValueMap.Create創建一個AsyncLocal實例並設置值。同時我們也可以看到,若在同一執行環境中,當前最新設置值與之前所設置值相同,此時將不會是覆蓋,而是直接返回。我們直接來到最後如下幾行代碼:

Thread.CurrentThread._executionContext =
      (!isFlowSuppressed && AsyncLocalValueMap.IsEmpty(newValues)) ?
      null : 
      new ExecutionContext(newValues, newChangeNotifications, isFlowSuppressed);

若默認使用Task默認線程池調度,即使線程池重用線程,其執行環境上下文也會不同,如此可說明將更能保證不會將線程數據泄露到另外一個線程中,也就是說在重用線程時,但將會保證異步本地實例會按照預期進行GC(個人以爲,理論上情況應該是這樣,這樣也能保證AsyncLocal是安全的)。至於其他關於如何進行值更改後事件通知,這裏就不再額外展開敘述。由於AsyncLocal使用淺拷貝,我們應保證存儲的數據類型不可變,若要修改AsyncLocal<T>實例值,必須保證異步上下文隔離且相互不會影響。

 

到這裏我們已完全清楚,AsyncLocal是針對異步控制流的良好支持,且數據可流動,當前線程AsyncLocal實例所存儲的數據可流動到異步任務控制流中的默認任務調度線程池的線程中。當然我們也可以調用如下執行環境上下文中的抑制流動方法來禁用數據流動

private static readonly AsyncLocal<string> asyncLocal = new AsyncLocal<string>();

static async Task Main(string[] args)
{
    asyncLocal.Value = "asyncLocal";

    using (ExecutionContext.SuppressFlow())
    {
      Task.Run(() =>
      {
        Console.WriteLine($"Inside child task: {asyncLocal.Value}");

      }).Wait();
    }

    Console.WriteLine($"after await:{asyncLocal.Value}");

    Console.ReadLine();
}

此時在其任務內部打印的值將爲空。最後,我們再來對AsyncLocal做一個最終總結

總結

💡 AsyncLocal出現於.NET Framework 4.6+(包含4.6)、CoreCLR

💡 AsyncLocal是每個ExecutionContext實例的一個變量,它並非如同ThreadLocal基於特定線程的持久化數據存儲

💡 若需要基於本地環境的異步控制流,使用AsyncLocal而非ThreadLocal,在線程池中重用線程時,ThreadLocal會保留之前值(基於理論猜測),而AsyncLocal不會

💡 AsyncLocal在每次async/await後,都將重新生成一個新的異步執行上下文環境,父異步上下文執行環境和子異步上下文執行環境完全隔離且互不影響

💡 AsyncLocal進行異步控制流時,由於內部對數據進行淺拷貝,確保其實例類型參數應爲不可變數據類型

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章