多线程共享变量和 AsyncLocal

·

3 min read

1. 简介

  • 普通共享变量:

    • 在某个类上用静态属性的方式即可。
  • 多线程共享变量

    • 希望能将这个变量的共享范围缩小到单个线程

    • 无关系的B线程无法访问到A线程的值;

[ThreadStatic]特性、ThreadLocal<T>CallContextAsyncLocal<T> 都具备这个特性。

例子:

由于 .NET Core 不再实现 CallContext,所以下列代码只能在 .NET Framework 中执行

class Program
{
    //对照
    private static string _normalStatic;

    [ThreadStatic]
    private static string _threadStatic;
    private static ThreadLocal<string> _threadLocal = new ThreadLocal<string>();
    private static AsyncLocal<string> _asyncLocal = new AsyncLocal<string>();
    static void Main(string[] args)
    {
        Parallel.For(0, 4, _ =>
        {
            var threadId = Thread.CurrentThread.ManagedThreadId;

            var value = $"这是来自线程{threadId}的数据";

            _normalStatic = value;
            _threadStatic = value;
            CallContext.SetData("value", value);
            _threadLocal.Value = value;
            _asyncLocal.Value = value;

            Console.WriteLine($"Use Normal;                Thread:{threadId}; Value:{_normalStatic}");
            Console.WriteLine($"Use ThreadStaticAttribute; Thread:{threadId}; Value:{_threadStatic}");
            Console.WriteLine($"Use CallContext;           Thread:{threadId}; Value:{CallContext.GetData("value")}");
            Console.WriteLine($"Use ThreadLocal;           Thread:{threadId}; Value:{_threadLocal.Value}");
            Console.WriteLine($"Use AsyncLocal;            Thread:{threadId}; Value:{_asyncLocal.Value}");
        });

        Console.Read();
    }
}

输出:

Use Normal;                Thread:15; Value:10
Use [ThreadStatic];        Thread:15; Value:15
Use Normal;                Thread:10; Value:10
Use Normal;                Thread:8; Value:10
Use [ThreadStatic];        Thread:8; Value:8
Use CallContext;           Thread:8; Value:8
Use [ThreadStatic];        Thread:10; Value:10
Use CallContext;           Thread:10; Value:10
Use CallContext;           Thread:15; Value:15
Use ThreadLocal;           Thread:15; Value:15
Use ThreadLocal;           Thread:8; Value:8
Use AsyncLocal;            Thread:8; Value:8
Use ThreadLocal;           Thread:10; Value:10
Use AsyncLocal;            Thread:10; Value:10
Use AsyncLocal;            Thread:15; Value:15

结论:

  • Normal 为对照组

  • Nomal 的 Thread 与 Value 值不同,因为读到了其他线程修改的值

  • 其他的类型,存储的值,在 Parallel 启动的线程间是隔离的

2. 异步下的共享变量

日常开发过程中,我们经常遇到异步的场景。

异步可能会导致代码执行线程的切换。

例如:

测试:[ThreadStatic]特性、ThreadLocal<T>AsyncLocal<T> ,三种共享变量被异步代码赋值后的表现。

class Program
{
    [ThreadStatic]
    private static string _threadStatic;
    private static ThreadLocal<string> _threadLocal = new ThreadLocal<string>();
    private static AsyncLocal<string> _asyncLocal = new AsyncLocal<string>();
    static void Main(string[] args)
    {
        _threadStatic = "set";
        _threadLocal.Value = "set";
        _asyncLocal.Value = "set";
        PrintValuesInAnotherThread();
        Console.ReadKey();
    }

    private static void PrintValuesInAnotherThread()
    {
        Task.Run(() =>
        {
            Console.WriteLine($"ThreadStatic: {_threadStatic}");
            Console.WriteLine($"ThreadLocal: {_threadLocal.Value}");
            Console.WriteLine($"AsyncLocal: {_asyncLocal.Value}");
        });
    }
}

输出:

ThreadStatic: 
ThreadLocal: 
AsyncLocal: set

结论:

在异步发生后,线程被切换,只有 AsyncLocal 还能够保留原来的值.

  • CallContext 也可以实现这个需求,但 .Net Core 没有被实现,这里就不过多说明。

我们总结一下这些变量的表现:

实现方式DotNetFxDotNetCore是否支持数据向辅助线程的
[ThreadStatic]
ThreadLocal
CallContext.SetData(string name, object data)仅当参数 data 对应的类型实现了 ILogicalThreadAffinative 接口时支持
CallContext.LogicalSetData(string name, object data)
AsyncLocal

辅助线程: 用于处理后台任务,用户不必等待就可以继续使用应用程序,比如线程池线程。

注意:

  • [ThreadStatic]特性、ThreadLocal<T> 最好不要用在线程池线程

    • 线程池线程是可重用的,线程不会销毁,当线程被重用时,之前使用保存的值依然存在,可能造成影响
  • 使用 AsyncLocal<T> 可以用在线程池线程

    • 线程使用后回归线程池, AsyncLocal<T> 的状态会被清除,无法访问之前的值
  • new Task(...) 默认不是新建一个线程,而是使用线程池线程

3. 解析 AsyncLocal

  • AsyncLocal<T>Value 属性的真正的数据存取是通过 ExecutionContextinternal 的方法 GetLocalValueSetLocalValue 将数据存到 当前ExecutionContext 上的 m_localValues 字段上

    • ExecutionContext 会根据执行环境进行流动,详见 《ExecutionContext(执行上下文)综述》

    • 简单描述就是,线程发生切换的时候, ExecutionContext 会在前一个线程中被捕获,流向下一个线程,它所保存的数据也就随之流动了

      • 在所有会发生线程切换的地方,基础类库(BCL) 都为我们封装好了对 ExecutionContext 的捕获

      • 例如:

        • new Thread(...).Start()

        • new Task(...).Start()

        • Task.Run(...)

        • ThreadPool.QueueUserWorkItem(...)

        • await 语法糖

  • m_localValues 类型是 IAsyncLocalValueMap

3.1. IAsyncLocalValueMap 的实现

以下为基础设施提供的实现:

类型元素个数
EmptyAsyncLocalValueMap0
OneElementAsyncLocalValueMap1
TwoElementAsyncLocalValueMap2
ThreeElementAsyncLocalValueMap3
MultiElementAsyncLocalValueMap4 ~ 16
ManyElementAsyncLocalValueMap\> 16

随着 ExecutionContext 所关联的 AsyncLocal 数量的增加, IAsyncLocalValueMap实现将会在 ExecutionContextSetLocalValue 方法中被不断替换

  • 查询的时间复杂度和空间复杂度依次递增

3.2. 结论

  • AsyncLocal 类型存储数据,是在自己线程的 ExecutionContext

  • ExecutionContext 的实例会随着异步或者多线程的启动而被流向执行后续代码的其他线程,保证了启动异步的线程存储的数据可以被访问到

  • 数据存到 IAsyncLocalValueMap 类型的变量中,此变量会根据存储的 AsyncLocal 变量个数而切换实现

    • 支持存储量越大的实现类型,性能越