浅谈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方法内部并未使用异步,所以能打印出所设置的值,这说明:每次进行实际的aysnc/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在每次asyn/await后,都将重新生成一个新的异步执行上下文环境,父异步上下文执行环境和子异步上下文执行环境完全隔离且互不影响

💡 AsyncLocal进行异步控制流时,由于内部对数据进行浅拷贝,确保其实例类型参数应为不可变数据类型

Tags: