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

Jeffcky發表於2020-11-29

前言

最近檢視有關框架原始碼,發現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進行非同步控制流時,由於內部對資料進行淺拷貝,確保其例項型別引數應為不可變資料型別

相關文章