为什么需要 AsyncLocal?

在 ASP.NET Core 应用中,一次 HTTP 请求会流经中间件、过滤器、控制器、业务服务等多层组件。我们经常需要在这些层级之间传递一些「请求范围」的数据——当前登录用户是谁、客户端 IP 是多少、TraceId 是什么。

最直觉的做法是用静态变量:

csharp // 静态变量在多线程下会串值! public static class CurrentUser { public static string UserId { get; set; } }

但静态变量是进程级共享的,多线程并发请求会互相覆盖。

那 ThreadLocal 呢?ThreadLocal 确实做到了线程隔离,但它在异步场景下有致命缺陷:await 之后线程会切换,ThreadLocal 的值就丢了。

`csharp var local = new ThreadLocal(); local.Value = “初始化线程”;

await Task.Delay(100); // await 后可能切到另一个线程

Console.WriteLine(local.Value); // 输出 null!因为换了线程 `

这就是 AsyncLocal 诞生的原因——它跟着 ExecutionContext(执行上下文) 走,而不是跟着物理线程走。

AsyncLocal 的核心原理

理解 AsyncLocal,先要理解 .NET 的两个上下文概念:

上下文 跟随对象 作用
ThreadLocal 物理线程(Thread) 同一线程内共享
AsyncLocal 执行上下文(ExecutionContext) 同一条异步调用链共享

在 async/await 的世界里,一个「逻辑执行流」可能跨越多个物理线程。ExecutionContext 是这条逻辑流的「身份证」,AsyncLocal 的值就存在这里。当 await 恢复执行时,框架会把原始 ExecutionContext 一并恢复,AsyncLocal 的值也就回来了。

mermaid flowchart LR A[请求到达 Thread-1] --> B[中间件设置 AsyncLocal] B --> C[await Task.Delay] C --> D[恢复到 Thread-3<br/>ExecutionContext 跟随] D --> E[业务层读取 AsyncLocal<br/>值还在]

SwitchData 项目中的完整实战

SwitchData 是一个典型的 ASP.NET Core 三层架构项目,用户上下文需要贯穿中间件到过滤器到控制器到业务服务到日志入库整条链路。下面看看它是如何用 AsyncLocal 实现的。

第一步:定义用户上下文数据模型

`csharp // SwitchData.Business/UserContext.cs public enum ApplicationType { Web, // ASP.NET Core MVC Win, // WinForms API // REST API }

public class UserContext { public string UserId { get; init; } public string UserName { get; init; } public string Ip { get; init; } public ApplicationType AppType { get; init; } } `

init 属性确保上下文对象一旦创建就不可变,避免被中间环节意外篡改。

第二步:AsyncLocal 静态包装

`csharp public static class CurrentUserContext { // AsyncLocal 的值存储在 ExecutionContext 中 private static readonly AsyncLocal _current = new();

public static UserContext Current
{
    get => _current.Value;
    set => _current.Value = value;
}

// 便捷访问器,避免业务层到处写 Current?.UserId
public static string UserId => _current.Value?.UserId;
public static string UserName => _current.Value?.UserName;

} `

核心就是 AsyncLocal 这一行。注意它声明为 static readonly——我们只需要一个 AsyncLocal 实例,它的 Value 由 ExecutionContext 自动隔离。

第三步:中间件中设置上下文

在请求管道最前端的中间件中,从 JWT 或 API 签名头提取用户信息,写入 AsyncLocal:

`csharp // SwitchData.Api/Middleware/UserContextMiddleware.cs public class UserContextMiddleware(RequestDelegate next) { public async Task InvokeAsync(HttpContext context) { var user = context.User; string userId, userName;

    if (user?.Identity?.IsAuthenticated == true)
    {
        userId = user.FindFirst("UserId")?.Value;
        userName = user.FindFirst("UserName")?.Value;
    }
    else
    {
        userId = context.Request.Headers["X-User-Id"];
    }

    // 关键一行:写入 AsyncLocal
    CurrentUserContext.Current = new UserContext
    {
        UserId = userId,
        UserName = userName,
        Ip = context.GetClientIpAddress(),
        AppType = ApplicationType.API
    };

    await next(context);
    // 这里不用手动清理——AsyncLocal 跟随 ExecutionContext,
    // 请求结束后 ExecutionContext 被回收,值自然消失
}

} `

第四步:深层业务代码中读取

AsyncLocal 的魔法在于——哪怕中间跨了 N 层 await、Task.Run、甚至并行分支,只要在同一条逻辑执行流里,值都能读到。

下面是项目中的数据库日志过滤器 DbLoggingActionFilter,它在 Action 执行完毕后用 Task.Run 异步记录操作日志:

`csharp // SwitchData.Api/Filters/DbLoggingActionFilter.cs public class DbLoggingActionFilter : IAsyncActionFilter { public async Task OnActionExecutionAsync( ActionExecutingContext context, ActionExecutionDelegate next) { var executedContext = await next();

    // 注意:这里用了 Task.Run 切到线程池线程
    // 但 CurrentUserContext.UserName 仍然能正确读到!
    _ = LogBusinessOperationAsync(...);
}

private async Task LogBusinessOperationAsync(...)
{
    await Task.Run(() =>
    {
        DbLogHelper.AddLog(
            ...,
            user: CurrentUserContext.UserName, // 异步切线程后仍能读到!
            ...
        );
    }).ConfigureAwait(false);
}

} `

如果这里用的是 ThreadLocal,Task.Run 切线程后值就丢了,会记录一条用户名为空的日志。而 AsyncLocal 跟着 ExecutionContext 走,不管物理线程怎么切都能保持正确。

第五步:业务服务层透明使用

AsyncLocal 最大的好处是对业务代码完全透明。任何层级——Service、Repository、Filter、BackgroundTask——只要在同一条调用链里,都可以直接:

`csharp public async Task QueryPcfDataAsync(string strategyId) { var userId = CurrentUserContext.UserId; // 直接拿 var ip = CurrentUserContext.Current?.Ip; // 或者拿完整对象

Logger.Info($"用户 {userId} 从 {ip} 查询策略 {strategyId}");
// ... 业务逻辑

} `

不需要层层传参,不需要 DI 注入,干净利落。

AsyncLocal 的值 vs 作用域

AsyncLocal 有两种写入方式,行为不同:

`csharp var local = new AsyncLocal();

// 方式 A:直接赋值(写入当前执行上下文) local.Value = “A”; await SomeAsync(); local.Value; // “A”

// 方式 B:使用 ExecutionContextScope 隔离子作用域 using (var scope = ExecutionContext.SuppressFlow()) { // 这个 scope 内对 local 的修改不会传播到父上下文 local.Value = “B”; } // scope 结束后,local.Value 恢复为 “A” `

在 SwitchData 的场景里,中间件给每个请求独立设置一次,请求之间天然隔离,所以直接用 local.Value = xxx 就够了,不需要 scope 模式。

常见坑点与最佳实践

1. BackgroundService / Hangfire 中读不到

AsyncLocal 跟随 ExecutionContext,而后台任务(BackgroundService、Hangfire Job)不会自动继承请求的 ExecutionContext。在这些场景下 CurrentUserContext.Current 会是 null。

处理方式:在入队时把需要的上下文数据显式序列化进 Job 参数,不要依赖 AsyncLocal 穿透后台边界。

2. Parallel.ForEach / PLINQ 中行为不确定

Parallel.ForEach 和 PLINQ 会使用线程池并可能分支 ExecutionContext,AsyncLocal 的值在并行分支中可能读到父级值,也可能因为线程池线程复用到旧值。

建议:并行循环内如果需要独立的上下文,用局部变量传入,不要依赖 AsyncLocal。

3. AsyncLocal vs IHttpContextAccessor

另一种常见方案是注入 IHttpContextAccessor,通过 HttpContext.User 获取用户信息。但两者适用场景不同:

对比项 AsyncLocal IHttpContextAccessor
性能 极低(内存直接访问) 需锁 + 字典查找
跨层传递 自动跟随调用链 需 DI 注入
BackgroundService 自然丢失 也丢失
适用范围 Web + Win + API 通用 仅限 HTTP 请求

AsyncLocal 方案对 SwitchData 特别合适——它同时支持 Web、WinForms、API 三种宿主,统一用同一套 CurrentUserContext。

4. Memory Leak 顾虑

AsyncLocal 的值会在 ExecutionContext 存活期间持有引用。如果放了大对象(比如整个 HttpContext),可能导致内存无法回收。建议只放不可变的轻量 DTO,像 UserContext 这样几个 string 字段就好。

总结

AsyncLocal 是 .NET 异步编程中被低估但极其实用的基础设施。它让我们可以像用静态变量一样自然地传递请求上下文,却不会在线程切换时丢失。SwitchData 项目中,仅 CurrentUserContext 就被 60+ 个文件引用——从中间件到过滤器、从控制器到业务服务、从 Web 到 WinForms,整条调用链零参数传递。

核心要点回顾:

  • AsyncLocal 跟随 ExecutionContext,不跟随物理线程——这是它和 ThreadLocal 的本质区别
  • 中间件设置一次,整条异步调用链自动可见——业务代码零侵入
  • 只放轻量不可变对象——避免内存泄漏风险
  • 后台任务边界显式传递——AsyncLocal 不会跨 BackgroundService/Hangfire 自动传播

下次你遇到异步调用链中传递上下文的需求,先想 AsyncLocal——它可能就是那个你一直在找的干净方案。

参考