C#教程

关注公众号 jb51net

关闭
首页 > 软件编程 > C#教程 > C#.NET AsyncLocal 异步上下文透传

C#.NET AsyncLocal 异步上下文透传实战

作者:唐青枫

本文主要介绍了C#.NET AsyncLocal 异步上下文透传实战,文中通过示例代码介绍的非常详细,对大家的学习或者工作具有一定的参考学习价值,需要的朋友们下面随着小编来一起学习学习吧

简介

异步代码一多,参数传递很快就会开始变味。

最常见的场景是这样:

如果每一层都靠方法参数往下传,代码会越来越啰嗦。

这时候很多人会先想到:

AsyncLocal<T> 就是专门解决这类问题的。

一句话先说透:

AsyncLocal<T> 绑定的不是线程,而是异步调用链的执行上下文。

所以这篇文章重点会放在几件事上:

AsyncLocal<T>是什么?

AsyncLocal<T> 位于:

System.Threading

它的作用可以直接理解成:

先别急着把它想得太玄乎。

它并不是全局变量,也不是线程变量,更不是锁。

更准确的说法是:

AsyncLocal<T> 是一份“跟着当前逻辑调用链走”的上下文数据槽位。

为什么会需要它?

先看一个非常典型的需求。

接口请求进来时生成一个链路 ID:

string traceId = Guid.NewGuid().ToString("N");

随后调用过程可能会经过:

如果每一层都这样传:

await service.CreateOrderAsync(orderDto, traceId);

再继续往下:

await repository.SaveAsync(order, traceId);

很快就会出现两个问题:

这类场景下,AsyncLocal<T> 会比较顺手:

它和ThreadLocal<T>的区别到底在哪?

这个点一定要先分清。

ThreadLocal<T>

绑定的是物理线程。

也就是说:

AsyncLocal<T>

绑定的是逻辑执行上下文。

也就是说:

一句话总结最方便记:

Demo 1:跨await保持上下文

先看最基础的例子。

using System;
using System.Threading;
using System.Threading.Tasks;

static AsyncLocal<string> TraceId = new();

static async Task Main()
{
    TraceId.Value = "trace-1001";

    Console.WriteLine($"Main 开始,线程:{Thread.CurrentThread.ManagedThreadId},值:{TraceId.Value}");

    await ProcessAsync();

    Console.WriteLine($"Main 结束,线程:{Thread.CurrentThread.ManagedThreadId},值:{TraceId.Value}");
}

static async Task ProcessAsync()
{
    Console.WriteLine($"ProcessAsync 开始,线程:{Thread.CurrentThread.ManagedThreadId},值:{TraceId.Value}");

    await Task.Delay(100);

    Console.WriteLine($"ProcessAsync 恢复后,线程:{Thread.CurrentThread.ManagedThreadId},值:{TraceId.Value}");
}

输出通常类似这样:

Main 开始,线程:1,值:trace-1001
ProcessAsync 开始,线程:1,值:trace-1001
ProcessAsync 恢复后,线程:7,值:trace-1001
Main 结束,线程:7,值:trace-1001

关键点不是线程号,而是:

这就是 AsyncLocal<T> 的核心价值。

Demo 2:和ThreadLocal<T>放在一起看,差别会非常直观

using System;
using System.Threading;
using System.Threading.Tasks;

static ThreadLocal<string> ThreadTrace = new(() => "empty-thread");
static AsyncLocal<string> AsyncTrace = new();

static async Task Main()
{
    ThreadTrace.Value = "thread-trace";
    AsyncTrace.Value = "async-trace";

    await Task.Delay(100).ConfigureAwait(false);

    Console.WriteLine($"ThreadLocal:{ThreadTrace.Value}");
    Console.WriteLine($"AsyncLocal:{AsyncTrace.Value}");
}

这里的结果最常见的是:

原因就在于两者绑定对象根本不同。

AsyncLocal<T>为什么能做到这件事?

背后关键不是 AsyncLocal<T> 自己,而是 .NET 的:

ExecutionContext

可以把它想成一份“当前执行环境的上下文包裹”。

这个包裹里可以带很多信息,其中就包括 AsyncLocal<T> 的值。

当异步方法遇到 await 时,运行时通常会做这些事:

所以真正流动的不是线程,而是上下文。

Demo 3:父流程设置值,子流程可以直接读取

using System;
using System.Threading;
using System.Threading.Tasks;

static AsyncLocal<string> CurrentUser = new();

static async Task Main()
{
    CurrentUser.Value = "admin";
    await CreateOrderAsync();
}

static async Task CreateOrderAsync()
{
    Console.WriteLine($"CreateOrderAsync: {CurrentUser.Value}");
    await SaveOrderAsync();
}

static async Task SaveOrderAsync()
{
    await Task.Delay(50);
    Console.WriteLine($"SaveOrderAsync: {CurrentUser.Value}");
}

输出会保持一致:

CreateOrderAsync: admin
SaveOrderAsync: admin

这正是它在请求上下文、租户上下文、日志作用域里被频繁使用的原因。

Demo 4:子流程改值,父流程不会被永久污染

这是 AsyncLocal<T> 很容易让人误判的地方。

很多人第一次接触时,会以为它就是一份所有子流程共享的全局变量。实际上不是。

看例子:

using System;
using System.Threading;
using System.Threading.Tasks;

static AsyncLocal<string> Context = new();

static async Task Main()
{
    Context.Value = "root";

    Console.WriteLine($"Main 调用前:{Context.Value}");
    await OuterAsync();
    Console.WriteLine($"Main 调用后:{Context.Value}");
}

static async Task OuterAsync()
{
    Console.WriteLine($"OuterAsync 开始:{Context.Value}");

    Context.Value = "outer";
    await InnerAsync();

    Console.WriteLine($"OuterAsync 结束:{Context.Value}");
}

static async Task InnerAsync()
{
    Console.WriteLine($"InnerAsync 开始:{Context.Value}");

    Context.Value = "inner";
    await Task.Delay(50);

    Console.WriteLine($"InnerAsync 结束:{Context.Value}");
}

一类常见输出会像这样:

Main 调用前:root
OuterAsync 开始:root
InnerAsync 开始:outer
InnerAsync 结束:inner
OuterAsync 结束:outer
Main 调用后:root

这个现象最值得记住:

所以更准确的理解应该是:

AsyncLocal<T> 更像“上下文作用域”,不是单纯的一份共享变量。

Demo 5:最贴近项目的场景,链路追踪TraceId

这个例子最接近真实项目。

using System;
using System.Threading;
using System.Threading.Tasks;

public static class TraceContext
{
    private static readonly AsyncLocal<string?> _traceId = new();

    public static string? TraceId
    {
        get => _traceId.Value;
        set => _traceId.Value = value;
    }
}

public sealed class OrderService
{
    public async Task CreateAsync()
    {
        Console.WriteLine($"[Service] TraceId={TraceContext.TraceId}");
        await new OrderRepository().SaveAsync();
    }
}

public sealed class OrderRepository
{
    public async Task SaveAsync()
    {
        await Task.Delay(50);
        Console.WriteLine($"[Repository] TraceId={TraceContext.TraceId}");
    }
}

static async Task Main()
{
    TraceContext.TraceId = Guid.NewGuid().ToString("N");

    try
    {
        await new OrderService().CreateAsync();
    }
    finally
    {
        TraceContext.TraceId = null;
    }
}

这种模式非常适合:

最关键的收益是:

Demo 6:引用类型是个大坑

这点必须单独讲。

很多人知道 AsyncLocal<T> 能隔离上下文,就误以为里面放引用类型也天然安全。其实并不是。

看这个例子:

using System;
using System.Threading;
using System.Threading.Tasks;

public sealed class RequestInfo
{
    public string TraceId { get; set; } = string.Empty;
}

static AsyncLocal<RequestInfo> RequestContext = new();

static async Task Main()
{
    RequestContext.Value = new RequestInfo { TraceId = "root-trace" };

    await Task.Run(async () =>
    {
        Console.WriteLine($"子任务开始:{RequestContext.Value.TraceId}");

        RequestContext.Value.TraceId = "child-updated";
        await Task.Delay(50);
    });

    Console.WriteLine($"主流程恢复后:{RequestContext.Value.TraceId}");
}

这里很可能输出:

子任务开始:root-trace
主流程恢复后:child-updated

原因不是 AsyncLocal<T> 失效了,而是:

所以实战里最好优先遵循这个原则:

如果非要放复杂对象,最好按“整体替换”来写,而不是到处改内部属性。

Demo 7:变化通知

AsyncLocal<T> 还有一个不算常用但挺有意思的能力:值变化通知。

using System;
using System.Threading;

var local = new AsyncLocal<string?>(args =>
{
    Console.WriteLine(
        $"上下文变化:旧值={args.PreviousValue ?? "<null>"},新值={args.CurrentValue ?? "<null>"}," +
        $"线程切换={args.ThreadContextChanged}");
});

local.Value = "A";
local.Value = "B";
local.Value = null;

这个能力更适合:

业务代码里一般不需要到处用,但排查问题时很有帮助。

它和静态变量的边界是什么?

这一点也很容易混。

静态变量

是全局共享的。

如果多个请求同时进来,都改同一个静态字段,数据就会互相覆盖。

AsyncLocal<T>

通常是“静态字段 + 每条异步调用链各自一份值”的组合。

也就是说:

这也是为什么框架里很多上下文访问器,会把 AsyncLocal<T> 写成 static readonly 字段。

什么时候适合用AsyncLocal<T>?

比较适合:

不太适合:

为什么不建议往里面塞大对象?

原因有两个。

1. 它的定位不是缓存容器

AsyncLocal<T> 最适合装的是“小而关键的上下文信息”,比如 ID、名称、轻量上下文对象。

2. 上下文传播本身也有成本

异步链路越复杂,传播越频繁,塞的对象越重,排查问题和控制生命周期就越麻烦。

尤其是在高并发服务里,AsyncLocal<T> 应该尽量保持轻量。

后台任务场景一定要格外小心

还有一个很容易踩坑的点:

Task.Run 默认会捕获当前 ExecutionContext,也就意味着会把当前 AsyncLocal<T> 一起带过去。

这有时是好事,有时反而是坏事。

例如某个请求里启动了一个后台任务:

_ = Task.Run(() =>
{
    Console.WriteLine(TraceContext.TraceId);
});

如果本意只是“顺手丢个后台工作”,却不希望把请求上下文一起传过去,那这个默认行为就可能造成误判甚至污染。

这种时候要意识到一件事:

AsyncLocal<T> 默认是会随执行上下文流动的,不是只在当前方法里生效。

高级一点的控制:禁止上下文流动

如果确实明确不想把当前上下文传给后台任务,可以用:

using System.Threading;

using (ExecutionContext.SuppressFlow())
{
    _ = Task.Run(() =>
    {
        Console.WriteLine(TraceContext.TraceId); // 大概率拿不到原上下文值
    });
}

这个 API 不算日常高频,但在基础设施代码里很有用。

它适合那种语义非常明确的场景:

一个更完整的实战写法:带作用域的上下文包装

项目里直接到处写:

TraceContext.TraceId = xxx;

时间长了很容易忘记恢复。

更稳一点的方式是做一个小作用域包装:

using System;
using System.Threading;

public static class TraceContext
{
    private static readonly AsyncLocal<string?> _traceId = new();

    public static string? TraceId
    {
        get => _traceId.Value;
        private set => _traceId.Value = value;
    }

    public static IDisposable BeginScope(string traceId)
    {
        string? previous = TraceId;
        TraceId = traceId;
        return new RestoreScope(previous);
    }

    private sealed class RestoreScope : IDisposable
    {
        private readonly string? _previous;

        public RestoreScope(string? previous)
        {
            _previous = previous;
        }

        public void Dispose()
        {
            TraceId = _previous;
        }
    }
}

使用时:

using (TraceContext.BeginScope(Guid.NewGuid().ToString("N")))
{
    await service.CreateAsync();
}

这种写法的好处很直接:

它和HttpContext.Items、方法参数传递怎么选?

这几个工具也经常被放在一起比较。

方法参数传递

优点是显式、清楚、最容易追踪。

缺点是透传链路一长,签名会越来越臃肿。

HttpContext.Items

适合明确绑定在 ASP.NET Core 请求对象上的数据。

但它依赖 HttpContext,脱离 Web 请求上下文就不好用了。

AsyncLocal<T>

适合那种:

最实用的判断标准可以这么记:

总结

AsyncLocal<T> 最核心的价值,不是“存个值”这么简单,而是:

但边界也必须记清楚:

AsyncLocal<T> 不是把数据绑在线程上,而是把数据挂在当前异步调用链上。

到此这篇关于C#.NET AsyncLocal 异步上下文透传实战的文章就介绍到这了,更多相关C#.NET AsyncLocal 异步上下文透传内容请搜索脚本之家以前的文章或继续浏览下面的相关文章希望大家以后多多支持脚本之家!

您可能感兴趣的文章:
阅读全文