13. ThreadLocal 与 AsyncLocal:线程本地存储
本章 GitHub 仓库:csharp-concurrency-cookbook ⭐
欢迎 Star 和 Fork!所有代码示例都可以在仓库中找到并运行。
本章导读
本文目标:搞清楚
ThreadLocal<T>和AsyncLocal<T>各自解决什么问题;深入AsyncLocal<T>的ExecutionContext原理与源码机制,彻底理解”向下继承、向上隔离、写时复制”三条核心规则;以及如何在 ASP.NET Core 中用AsyncLocal实现生产级 TraceId 追踪和优雅的CurrentUser全局上下文,彻底告别IHttpContextAccessor注入的样板代码。
上一篇《并发集合与线程安全类型》里,我们聊的是”多线程共享同一份数据”的问题。今天翻个方向,聊聊每个线程(或每条异步调用链)独占自己一份数据的问题——这就是”线程本地存储(Thread-Local Storage,TLS)”。
先来个灵魂拷问:你有没有遇到过这种需求?
“我有一个
Random实例,我想多线程并发用它生成随机数,但Random不是线程安全的,加锁又太慢——怎么办?”
或者:
“我在处理 HTTP 请求,我想让 TraceId(请求追踪 ID)在整个调用链里都能取到,但我又不想把它一层一层地当参数传下去——怎么搞?”
这两个问题,分别是 ThreadLocal<T> 和 AsyncLocal<T> 的经典使用场景。学完这篇,这两个问题你都能给出漂亮的解答。
🧵 从最古老的方式说起:[ThreadStatic]
在 ThreadLocal<T> 出现之前(.NET 4.0 以前),我们用 [ThreadStatic] 特性来实现线程本地存储。
[ThreadStatic]
private static int _counter = 10;
[ThreadStatic] 的作用很简单:让每个线程都拥有这个静态字段的独立副本。线程A改了它,不影响线程B。
听起来很美好,但有一个经典陷阱——
️ [ThreadStatic] 的初始化陷阱
[ThreadStatic]
private static int _counter = 10; // 危险!
var t = new Thread(() =>
{
Console.WriteLine(_counter); // 输出 0,不是 10!
});
t.Start();
t.Join();
为什么子线程拿到的是 0 而不是 10?
因为 = 10 这个静态字段初始化,只在类型被加载时执行一次,而类型加载发生在主线程。子线程拿到的是该字段的 默认值(int 是 0,引用类型是 null)。
正确做法:声明时不赋初值,在每个线程开始时手动初始化:
[ThreadStatic]
private static int _counter; // 不赋初值
// 在线程入口处手动初始化
_counter = 10;
[ThreadStatic] 还有另一个限制:只能用在静态字段上,不能用于属性、局部变量、实例字段。
ThreadLocal<T>:[ThreadStatic] 的进化版
.NET 4.0 带来了 ThreadLocal<T>,把上面那个”初始化陷阱”彻底解决了,同时增加了更多实用特性。
基本用法:工厂函数初始化
// 通过工厂函数初始化,每个线程第一次访问 .Value 时执行一次
private static readonly ThreadLocal<StringBuilder> _sb =
new(() => new StringBuilder());
工厂函数是懒加载的——第一次访问 .Value 才执行,并且每个线程的工厂函数调用是相互独立的。
var threads = Enumerable.Range(1, 3).Select(i => new Thread(() =>
{
// 每个线程都有自己独立的 StringBuilder 实例
_sb.Value!.Append($"来自线程{Thread.CurrentThread.ManagedThreadId}的消息");
Console.WriteLine(_sb.Value);
})).ToList();
threads.ForEach(t => t.Start());
threads.ForEach(t => t.Join());
// 记得释放!
_sb.Dispose();
ThreadLocal<T> 对比 [ThreadStatic]
| 特性 | [ThreadStatic] |
ThreadLocal<T> |
|---|---|---|
| 初始化方式 | 只有主线程执行静态初始化 | 每线程独立的工厂函数 |
| 适用范围 | 只能用于静态字段 | 可以是任意成员 |
| 追踪所有线程的值 | 不支持 | trackAllValues: true |
| 实现 IDisposable | 需要手动 Dispose | |
| 延迟初始化 |
trackAllValues:追踪所有线程的值
这是一个低调但好用的特性,适合”无锁多线程统计”的场景:
// trackAllValues: true —— 可以通过 .Values 拿到所有线程的值快照
using var perThreadCount = new ThreadLocal<int>(
valueFactory: () => 0,
trackAllValues: true);
var threads = Enumerable.Range(1, 5).Select(i => new Thread(() =>
{
perThreadCount.Value = i * 10; // 每个线程自己计数
})).ToList();
threads.ForEach(t => t.Start());
threads.ForEach(t => t.Join());
// 汇总所有线程的计数,无需任何锁!
var total = perThreadCount.Values.Sum();
Console.WriteLine($"总计:{total}");
这比用 lock 保护的全局计数器效率高很多,因为各线程写入时完全无竞争,只有最后汇总时才需要一次聚合。
️ ThreadLocal 在线程池中的”复用陷阱”
这是一个很多人踩过的坑,务必记清楚。
线程池里的线程是复用的——任务完成后线程不会销毁,而是回到池子里等下一个任务。如果你在 Task.Run 里设置了 ThreadLocal 却忘记清理,下一个跑在同一个线程上的任务就会读到脏数据:
private static readonly ThreadLocal<string?> _requestContext = new(() => null);
// 危险写法
var task1 = Task.Run(() =>
{
_requestContext.Value = "RequestId=AAA";
// ... 处理完,但忘了清理
});
await task1;
// 如果 task2 分到同一个线程,会读到 "RequestId=AAA"!
var task2 = Task.Run(() =>
{
Console.WriteLine(_requestContext.Value); // 可能是 "RequestId=AAA",数据污染!
});
await task2;
正确做法:try/finally 保证清理:
// 正确写法
var task = Task.Run(() =>
{
try
{
_requestContext.Value = "RequestId=BBB";
// 处理业务逻辑
}
finally
{
_requestContext.Value = null; // 无论异常与否,都清理
}
});
说真的,这个坑让很多人调试了好几个小时才找到原因——线程池里的线程数量有限,一旦触发复用,问题就很难复现。只要在 Task.Run 里用了 ThreadLocal,就要养成写 try/finally 的习惯。
ThreadLocal 实战:线程安全的 Random
这是 ThreadLocal<T> 最经典的使用场景之一。System.Random 不是线程安全的,在老版本 .NET 中多线程共享一个 Random 会导致它内部状态损坏,所有后续调用只返回 0(经典 bug)。
// 每线程一个 Random 实例,完全无竞争
private static readonly ThreadLocal<Random> _random =
new(() => new Random(Thread.CurrentThread.ManagedThreadId * 31 + Environment.TickCount));
// 在任意线程中安全使用
var value = _random.Value!.Next(1, 100);
.NET 6+ 注意:
Random.Shared已经是官方线程安全实现,新项目直接用Random.Shared就行,不需要ThreadLocal<Random>了。但理解这个模式对于维护老代码非常重要。
AsyncLocal<T>:异步世界里的”上下文传播”
好,现在我们进入今天的重头戏——AsyncLocal<T>。
如果说 ThreadLocal<T> 是”跟踪线程”,那么 AsyncLocal<T> 是跟踪逻辑执行流(异步调用链)。
先看一个问题:在 async 方法里设置了 ThreadLocal 值,await 之后还在吗?
private static readonly ThreadLocal<string?> _tl = new(() => null);
async Task TestAsync()
{
_tl.Value = "hello";
Console.WriteLine(_tl.Value); // "hello"
await Task.Delay(100); // await 可能切换到另一个线程!
Console.WriteLine(_tl.Value); // 可能是 null
}
await 之后,运行时可能把你调度到另一个线程上继续执行,而那个线程的 ThreadLocal 副本是空的——值就”找不到”了。
AsyncLocal<T> 就是来解决这个问题的:
private static readonly AsyncLocal<string?> _al = new();
async Task TestAsync()
{
_al.Value = "hello";
await Task.Delay(100); // 无论切没切线程
Console.WriteLine(_al.Value); // 依然是 "hello"
}
深入原理:ExecutionContext 是如何工作的?
要真正理解 AsyncLocal<T>,必须先搞清楚 ExecutionContext ——这才是幕后的真正主角。
ExecutionContext 是什么?
ExecutionContext 是 .NET 运行时用来传递”执行环境”的容器,你可以把它想象成一个随着异步调用链自动流动的”背包”:线程切换了、await 了、Task.Run 了,这个背包都会跟着走。
AsyncLocal<T> 的值,就存在这个背包里。
源码结构速览(.NET 10)
我们来看看 .NET 运行时的实际源码(coreclr/ExecutionContext.cs):
// ExecutionContext 的核心字段(精简版)
public sealed class ExecutionContext
{
// AsyncLocal 的值就存在这个不可变映射里
// (是一个经过优化的不可变 AsyncLocal 键值对映射)
internal IAsyncLocalValueMap? m_localValues;
// 快照复制(这是"写时复制"的关键)
internal static ExecutionContext ShallowClone(ExecutionContext? ec)
{
return new ExecutionContext
{
m_localValues = ec?.m_localValues // 直接共享同一个 map 引用(浅拷贝!)
};
}
}
再看 AsyncLocal<T>.Value 的 set 实现:
// AsyncLocal<T>.Value 的 setter(精简版)
public T? Value
{
set
{
// 1. 获取当前线程的 ExecutionContext
var current = Thread.CurrentThread._executionContext;
// 2. 用新值创建一个新的 IAsyncLocalValueMap(不可变结构,写入 = 生成新副本)
var newValues = AsyncLocalValueMap.Create(this, value, current?.m_localValues);
// 3. 把新的 map 包装成一个新的 ExecutionContext,赋给当前线程
// ️ 注意:这是一个全新的 ExecutionContext 对象,不是修改原来那个!
Thread.CurrentThread._executionContext = new ExecutionContext { m_localValues = newValues };
// 4. 如果注册了值变更回调(ValueChanged),通知它
if (_valueChangedHandler != null)
_valueChangedHandler(new AsyncLocalValueChangedArgs<T>(...));
}
}
这几行代码揭示了最核心的机制:每次给 AsyncLocal<T>.Value 赋值,都会生成一个全新的 ExecutionContext 对象,老的那个完全不受影响。这就是”写时复制”的本质。
await 时发生了什么?
每次 await 一个 Task,编译器生成的状态机会这样处理(简化版):
// 编译器生成的状态机(概念代码)
void MoveNext()
{
// ... 进入 await 点 ...
// 捕获当前 ExecutionContext 的快照
var capturedContext = ExecutionContext.Capture();
// 把"恢复执行"这个动作(continuation)包装好,连同 ExecutionContext 一起投递给线程池
ThreadPool.QueueUserWorkItem(state =>
{
// 线程池线程上:用捕获的 ExecutionContext 恢复执行
ExecutionContext.Run(capturedContext, s => continuation(s), state);
}, null);
}
ExecutionContext.Capture() 拿到的是当前上下文的快照(浅拷贝),ExecutionContext.Run() 则在指定上下文下执行 continuation。整个过程 AsyncLocal 的值都随着这个快照流转,不依赖具体是哪个线程。
核心规则图解:向下继承 · 向上隔离 · 写时复制
这三条规则是 AsyncLocal<T> 的灵魂,搞清楚了就彻底掌握了它。
规则一:向下继承(Downward Propagation)
父级设置的值,子级(子方法、子 Task)默认可以读到。
flowchart TD Root[“Root<br/>TraceId = ‘REQ-001′”] ChildA[“ChildA 进入<br/>读到 TraceId = ‘REQ-001’ “] ChildB[“ChildB 进入<br/>读到 TraceId = ‘REQ-001’ “] Root –>|”await / Task.Run<br/>EC 快照传递(浅拷贝)”| ChildA Root –>|”await / Task.Run<br/>EC 快照传递(浅拷贝)”| ChildB style Root fill:#bbdefb,color:#1565c0,stroke:#1976d2,stroke-width:2px style ChildA fill:#c8e6c9,color:#2e7d32,stroke:#388e3c,stroke-width:2px style ChildB fill:#c8e6c9,color:#2e7d32,stroke:#388e3c,stroke-width:2px
原理:await ChildAsync() / Task.Run(...) 时,运行时将当前 ExecutionContext 快照传递给子执行流。子流一开始和父流指向同一个 m_localValues(浅拷贝,高效)。
规则二:向上隔离(Upward Isolation)
子级对 AsyncLocal 的修改,不会影响父级。
flowchart TD Root[“Root<br/>TraceId = ‘REQ-001′”] ChildA[“ChildA<br/>将 TraceId 改为 ‘CHILD-A'<br/>(创建新 ExecutionContext)”] RootAfter[“Root await 返回后<br/>TraceId 仍然是 ‘REQ-001’ “] Root –>|”await ChildA()<br/>EC 快照传递”| ChildA ChildA –>|”return(子 EC 消亡)<br/>父 EC 从未被修改”| RootAfter style Root fill:#bbdefb,color:#1565c0,stroke:#1976d2,stroke-width:2px style ChildA fill:#ffccbc,color:#d84315,stroke:#e64a19,stroke-width:2px style RootAfter fill:#c8e6c9,color:#2e7d32,stroke:#388e3c,stroke-width:2px
原理:子流修改 AsyncLocal.Value 时,会生成一个全新的 ExecutionContext 赋给当前线程。父流的那个 ExecutionContext 实例始终没有被碰过。
规则三:写时复制(Copy-on-Write)
修改触发”副本创建”,父子之间共享直到其中一方写入。
初始状态——子流继承父流快照,双方共享同一个 m_localValues 引用(零拷贝开销):
flowchart LR Root([“Root 流”]) ChildA1([“ChildA 流”]) subgraph EC0[“EC — 父子共享”] mv0[“m_localValues<br/>TraceId = ‘REQ-001′”] end Root –> mv0 ChildA1 –> mv0 style Root fill:#bbdefb,color:#1565c0,stroke:#1976d2,stroke-width:2px style ChildA1 fill:#bbdefb,color:#1565c0,stroke:#1976d2,stroke-width:2px style mv0 fill:#fff9c4,color:#f57f17,stroke:#f9a825,stroke-width:2px style EC0 fill:#e1bee7,color:#6a1b9a,stroke:#7b1fa2,stroke-width:2px
ChildA 执行 _traceId.Value = "CHILD-A"——写时复制,创建全新 EC,父流 EC 完全不受影响:
flowchart LR RootFlow([“Root 流”]) ChildFlow([“ChildA 流”]) subgraph ECRoot[“EC-Root — 未修改”] mvRoot[“m_localValues<br/>TraceId = ‘REQ-001′”] end subgraph ECChild[“EC-ChildA — 新创建”] mvChild[“m_localValues<br/>TraceId = ‘CHILD-A'”] end RootFlow –> mvRoot ChildFlow –> mvChild style RootFlow fill:#bbdefb,color:#1565c0,stroke:#1976d2,stroke-width:2px style ChildFlow fill:#ffccbc,color:#d84315,stroke:#e64a19,stroke-width:2px style mvRoot fill:#c8e6c9,color:#2e7d32,stroke:#388e3c,stroke-width:2px style mvChild fill:#ffccbc,color:#d84315,stroke:#e64a19,stroke-width:2px style ECRoot fill:#bbdefb,color:#1565c0,stroke:#1976d2,stroke-width:2px style ECChild fill:#ffccbc,color:#d84315,stroke:#e64a19,stroke-width:2px
IAsyncLocalValueMap 是一个不可变(immutable)的键值映射,每次写入都会生成新的映射实例,旧的映射不变。这就是为什么父流永远看不到子流的修改。
完整时序图
sequenceDiagram participant Root as Root participant ChildA as ChildA participant ChildB as ChildB Root->>Root: _ctx.Value = “REQ”<br/>(EC: TraceId=”REQ”) Root->>ChildA: await ChildA()<br/>传递 EC 快照(TraceId=”REQ”) ChildA->>ChildA: 读 TraceId = “REQ” (向下继承) ChildA->>ChildA: _ctx.Value = “CHILD”<br/>(创建新 EC,TraceId=”CHILD”) ChildA->>ChildB: await ChildB()<br/>传递 EC 快照(TraceId=”CHILD”) ChildB->>ChildB: 读 TraceId = “CHILD” (继承自 ChildA) Note over ChildB: await Task.Delay → 可能切换线程 ChildB->>ChildB: 切换后读 TraceId = “CHILD” <br/>(EC 随 continuation 流转,与线程无关) ChildB–>>ChildA: return ChildA–>>Root: return(ChildA 的新 EC 随栈帧消亡) Root->>Root: 读 TraceId = “REQ” (向上隔离)<br/>Root 的 EC 从未被修改
用代码验证三条规则
private static readonly AsyncLocal<string?> _ctx = new();
async Task VerifyRulesAsync()
{
// ── 规则1:向下继承 ──────────────────────────────────
_ctx.Value = "Root";
await Task.Run(async () =>
{
Console.WriteLine(_ctx.Value); // "Root" 子任务继承了父级的值
await Task.Delay(1);
Console.WriteLine(_ctx.Value); // "Root" await 后依然保持
});
// ── 规则2:向上隔离 ──────────────────────────────────
await ModifyInChildAsync();
Console.WriteLine(_ctx.Value); // "Root" 子方法修改不影响父级
// ── 规则3:写时复制(两个并发分支互不干扰)──────────
var t1 = ProcessAsync("Branch1", "B1");
var t2 = ProcessAsync("Branch2", "B2");
await Task.WhenAll(t1, t2);
Console.WriteLine(_ctx.Value); // "Root" 并发分支的修改都不影响主流
}
async Task ModifyInChildAsync()
{
Console.WriteLine(_ctx.Value); // "Root" 可以读到父级的值
_ctx.Value = "Child"; // 写时复制,创建新 EC
Console.WriteLine(_ctx.Value); // "Child"
await Task.Delay(1);
Console.WriteLine(_ctx.Value); // "Child" await 后自己的副本还在
} // 方法返回后,这个新 EC 随着栈帧消亡,父级的 EC 完全不受影响
async Task ProcessAsync(string name, string value)
{
_ctx.Value = value; // 每个分支都有自己独立的 EC 副本
await Task.Delay(10);
Console.WriteLine($"[{name}] {_ctx.Value}"); // 各自正确,互不干扰
}
历史 Bug:.NET 6 之前值类型的陷阱
这是一个鲜少被提及但很真实的历史问题,如果你维护老项目,务必了解。
在 .NET 6 之前,AsyncLocal<T> 在存储值类型(struct) 时存在一个隐蔽的问题:由于 IAsyncLocalValueMap 的实现方式,值类型会被装箱(Boxing)存储。这本身不是 bug,但有一个棘手的副作用:
问题复现(.NET Framework / .NET 5 及以下):
// 假设 T 是值类型 int
var al = new AsyncLocal<int>();
al.Value = 42;
await Task.Run(() =>
{
// 在某些旧版本运行时中,如果没有赋值(只是读),
// 对 ExecutionContext 的其他修改(比如设置了另一个 AsyncLocal)
// 可能触发 EC 重建,导致值类型副本的引用断开,
// 下次读取时拿到的是默认值 0 而不是 42
// (具体触发条件依赖运行时版本和 EC 的内部实现细节)
Console.WriteLine(al.Value); // 在受影响版本上:0(而不是 42)
});
根本原因:旧版 IAsyncLocalValueMap 在某些 slot 优化路径上,对值类型的处理与引用类型不一致,导致在 EC 快照复制时,值类型的 box 实例没有被正确传递。
解决方案(.NET 6 之前的防御性写法):
// 把值类型包装成引用类型(class),彻底避免问题
private static readonly AsyncLocal<IntWrapper?> _count = new();
class IntWrapper { public int Value { get; set; } }
// 最保险:用 string 传数字,完全避开结构体装箱问题
private static readonly AsyncLocal<string?> _countStr = new();
.NET 6 的修复:.NET 6 对 ExecutionContext 和 IAsyncLocalValueMap 的内部实现做了彻底重构,引入了新的 AsyncLocalValueMap 实现,正确处理了值类型的存储和传递。从 .NET 6 起,AsyncLocal<int>、AsyncLocal<bool> 等值类型用法是完全安全的。
项目用的是 .NET 10,这个 bug 不存在。但如果你接手 .NET 5 或更早的老项目,看到有人把
AsyncLocal<int>包装成AsyncLocal<MyClass>时,现在你知道原因了。
实战一:用 AsyncLocal 实现生产级 TraceId 追踪
下面是完整的生产级实现(代码见 AsyncLocalDemo 项目)。
架构设计
flowchart TD Request([“HTTP 请求<br/>(可携带 X-Trace-Id 请求头)”]) subgraph MW[“ASP.NET Core 中间件管道”] direction TB TM[“TraceMiddleware<br/>① 读取 / 生成 TraceId<br/>② TraceContext.Initialize → 写入 AsyncLocal<br/>③ 响应头写回 X-Trace-Id”] Auth[“UseAuthentication<br/>JWT 验证 → 填充 HttpContext.User”] CUM[“CurrentUserMiddleware<br/>从 Claims 解析用户信息<br/>CurrentUser.Set → 写入 AsyncLocal”] AuthZ[“UseAuthorization”] end subgraph BIZ[“业务层(任意层直接读取 AsyncLocal,无需注入)”] direction TB Ctrl[“Controller<br/>TraceContext.TraceId / CurrentUser.UserId”] Svc[“Service<br/>TraceContext.TraceId / CurrentUser.UserId”] Repo[“Repository<br/>TraceContext.TraceId”] end Finally[“TraceMiddleware finally 块<br/>记录请求总耗时日志”] Request –> TM TM –> Auth Auth –> CUM CUM –> AuthZ AuthZ –> Ctrl Ctrl –> Svc Svc –> Repo Repo –> Finally style Request fill:#bbdefb,color:#1565c0,stroke:#1976d2,stroke-width:2px style TM fill:#e1bee7,color:#6a1b9a,stroke:#7b1fa2,stroke-width:2px style Auth fill:#fff9c4,color:#f57f17,stroke:#f9a825,stroke-width:2px style CUM fill:#fff9c4,color:#f57f17,stroke:#f9a825,stroke-width:2px style AuthZ fill:#fff9c4,color:#f57f17,stroke:#f9a825,stroke-width:2px style Ctrl fill:#c8e6c9,color:#2e7d32,stroke:#388e3c,stroke-width:2px style Svc fill:#c8e6c9,color:#2e7d32,stroke:#388e3c,stroke-width:2px style Repo fill:#c8e6c9,color:#2e7d32,stroke:#388e3c,stroke-width:2px style Finally fill:#ffccbc,color:#d84315,stroke:#e64a19,stroke-width:2px style MW fill:#f3e5f5,color:#6a1b9a,stroke:#7b1fa2,stroke-width:1px style BIZ fill:#e8f5e9,color:#2e7d32,stroke:#388e3c,stroke-width:1px
TraceContext 静态类
public static class TraceContext
{
// 使用 AsyncLocal<string?> 存储 TraceId
private static readonly AsyncLocal<string?> _traceId = new();
private static readonly AsyncLocal<DateTime> _requestStartTime = new();
public static string TraceId => _traceId.Value ?? "N/A";
public static DateTime RequestStartTime => _requestStartTime.Value;
public static double ElapsedMs => (DateTime.UtcNow - _requestStartTime.Value).TotalMilliseconds;
// internal:只允许 TraceMiddleware 写入,业务代码只读
internal static void Initialize(string? traceId = null)
{
_traceId.Value = traceId ?? Guid.NewGuid().ToString("N")[..16].ToUpper();
_requestStartTime.Value = DateTime.UtcNow;
}
}
TraceMiddleware
public sealed class TraceMiddleware(RequestDelegate next, ILogger<TraceMiddleware> logger)
{
private const string TraceIdHeader = "X-Trace-Id";
public async Task InvokeAsync(HttpContext context)
{
// 1. 从请求头读取(支持链路追踪透传),否则自动生成
var incomingTraceId = context.Request.Headers[TraceIdHeader].FirstOrDefault();
TraceContext.Initialize(incomingTraceId);
// 2. 响应头写回 TraceId
context.Response.OnStarting(() =>
{
context.Response.Headers[TraceIdHeader] = TraceContext.TraceId;
return Task.CompletedTask;
});
// 3. ILogger Scope 让整个请求的所有日志自动携带 TraceId
using (logger.BeginScope(new Dictionary<string, object> { ["TraceId"] = TraceContext.TraceId }))
{
logger.LogInformation("请求开始 [{Method}] {Path}", context.Request.Method, context.Request.Path);
try
{
await next(context);
}
finally
{
logger.LogInformation(
"请求结束 StatusCode={StatusCode} Elapsed={Elapsed:F1}ms",
context.Response.StatusCode, TraceContext.ElapsedMs);
}
}
}
}
Service 层:零注入读取上下文
public sealed class OrderService(ILogger<OrderService> logger) : IOrderService
{
public async Task<IReadOnlyList<OrderDto>> GetMyOrdersAsync(CancellationToken ct = default)
{
// 直接读取 AsyncLocal 上下文,无需 IHttpContextAccessor 注入
var userId = CurrentUser.UserId;
var traceId = TraceContext.TraceId;
logger.LogInformation("[{TraceId}] GetMyOrders: 查询用户 {UserId}", traceId, userId);
await Task.Delay(10, ct); // 模拟数据库 I/O
// await 之后,TraceId 和 UserId 依然有效(ExecutionContext 流转保证)
logger.LogInformation("[{TraceId}] GetMyOrders 完成", TraceContext.TraceId);
return _db.Where(o => o.UserId == userId).ToList();
}
}
Program.cs 中间件注册顺序
// 第一步:TraceMiddleware 放在最前端
app.UseTracing();
app.UseAuthentication(); // JWT 认证,填充 HttpContext.User
// 第二步:CurrentUserMiddleware 必须在 UseAuthentication 之后
app.UseCurrentUser();
app.UseAuthorization();
app.MapControllers();
🧑 实战二:用 AsyncLocal 实现 CurrentUser,彻底告别 IHttpContextAccessor
这是我觉得 AsyncLocal<T> 最能体现价值的实战场景,解决的是一个让无数团队头疼的工程痛点。
原来的做法有什么问题?
// 传统写法:每个 Service 都注入 IHttpContextAccessor
// ServiceBase 基类
public abstract class ServiceBase
{
protected readonly IHttpContextAccessor _httpContextAccessor;
protected ServiceBase(IHttpContextAccessor httpContextAccessor)
=> _httpContextAccessor = httpContextAccessor;
protected string? CurrentUserId =>
_httpContextAccessor.HttpContext?.User?.FindFirstValue(ClaimTypes.NameIdentifier);
}
// 每个具体 Service 都得写这个构造函数
public class OrderService : ServiceBase
{
public OrderService(IHttpContextAccessor httpContextAccessor,
IRepository<Order> repo,
ILogger<OrderService> logger)
: base(httpContextAccessor) { ... }
}
public class ProductService : ServiceBase
{
public ProductService(IHttpContextAccessor httpContextAccessor,
IRepository<Product> repo)
: base(httpContextAccessor) { ... }
}
// ... 几十个 Service,每个都要这样写
这有几个问题:
- 每个 Service 都要多一个无聊的构造函数参数,纯粹是样板代码
- Service 层被迫依赖 HTTP 基础设施(
IHttpContextAccessor本质是对HttpContext的封装),破坏了分层架构的纯净性 - 单元测试麻烦:测试时需要 Mock
IHttpContextAccessor,配置繁琐
用 AsyncLocal 的新方案
// 新方案:CurrentUser 全局静态上下文
public static class CurrentUser
{
private static readonly AsyncLocal<UserInfo?> _userInfo = new();
public static UserInfo? Info => _userInfo.Value;
public static string? UserId => _userInfo.Value?.UserId;
public static string UserName => _userInfo.Value?.UserName ?? "Anonymous";
public static bool IsAuthenticated => _userInfo.Value?.IsAuthenticated == true;
public static bool IsInRole(string role) => _userInfo.Value?.IsInRole(role) == true;
// 仅供 CurrentUserMiddleware 调用
internal static void Set(ClaimsPrincipal? principal) { ... }
// 仅供单元测试使用
public static void SetForTesting(UserInfo? userInfo) => _userInfo.Value = userInfo;
}
// CurrentUserMiddleware:认证完成后,从 Claims 填充 CurrentUser
public sealed class CurrentUserMiddleware(RequestDelegate next)
{
public async Task InvokeAsync(HttpContext context)
{
// UseAuthentication 已经运行完毕,HttpContext.User 已填充
CurrentUser.Set(context.User);
await next(context);
}
}
// 新的 Service:构造函数干净了!
public class OrderService(ILogger<OrderService> logger) : IOrderService
{
public async Task<OrderDto> CreateOrderAsync(string productName, decimal amount)
{
// 直接读取,零注入
var userId = CurrentUser.UserId ?? throw new UnauthorizedAccessException();
var userName = CurrentUser.UserName;
// ...
}
}
public class ProductService(IRepository<Product> repo) : IProductService
{
// 同样干净,不需要注入 IHttpContextAccessor
}
为什么这样是线程安全的?
有人可能会担心:CurrentUser 是静态类,多个并发请求会不会互相串号?
不会。AsyncLocal<T> 的写时复制语义保证了每个请求有独立的 ExecutionContext:
sequenceDiagram participant MW1 as Middleware(请求1·Alice) participant MW2 as Middleware(请求2·Bob) participant Svc1 as OrderService(请求1) participant Svc2 as OrderService(请求2) Note over MW1,MW2: 两个请求并发进入,各自拥有独立的 ExecutionContext MW1->>MW1: CurrentUser.Set(Alice)<br/>写入 AsyncLocal → 创建新 EC1<br/>(仅影响请求1的执行流) MW2->>MW2: CurrentUser.Set(Bob)<br/>写入 AsyncLocal → 创建新 EC2<br/>(仅影响请求2的执行流) MW1->>Svc1: await(EC1 流转) MW2->>Svc2: await(EC2 流转) Svc1->>Svc1: CurrentUser.UserId = “alice-001” Svc2->>Svc2: CurrentUser.UserId = “bob-002” Note over Svc1,Svc2: 两个请求完全隔离,不存在”Alice 读到 Bob 的 UserId”的情况
单元测试怎么办?
这也是新方案的一个优点:测试时直接调用 SetForTesting,不需要繁琐地 Mock IHttpContextAccessor:
[Fact]
public async Task CreateOrder_ShouldReturnOrder_WhenUserAuthenticated()
{
// Arrange
CurrentUser.SetForTesting(new UserInfo
{
UserId = "test-user-001",
UserName = "TestUser",
Email = "test@example.com",
Roles = ["User"]
});
var service = new OrderService(Mock.Of<ILogger<OrderService>>());
// Act
var order = await service.CreateOrderAsync("MacBook Pro", 12999m);
// Assert
Assert.Equal("test-user-001", order.UserId);
}
️ 使用 AsyncLocal 的注意事项
1. 引用类型的”浅拷贝”陷阱
AsyncLocal<T> 的写时复制是对 Value 引用本身的复制,而不是对象的深拷贝!
// 陷阱:T 是 List<string>(引用类型)
var myList = new AsyncLocal<List<string>>();
myList.Value = new List<string> { "初始值" };
await Task.Run(() =>
{
// 子任务没有给 myList.Value 重新赋值——
// 子任务的 EC 和父任务的 EC 指向同一个 List 实例!
myList.Value!.Add("子任务的数据"); // ️ 这修改的是父任务看到的那个 List!
});
Console.WriteLine(myList.Value!.Count); // 2,父任务的 List 被改了!
正确做法:子任务写入时重新赋值新实例(触发写时复制):
await Task.Run(() =>
{
// 重新赋值 → 触发写时复制 → 创建新 EC → 父任务的 EC 不受影响
myList.Value = new List<string>(myList.Value!) { "子任务的数据" };
});
2. ValueChanged 回调
AsyncLocal<T> 构造函数可以传入一个回调,在值被修改(包括 EC 流转时)时触发:
var al = new AsyncLocal<string?>(change =>
{
Console.WriteLine($"值从 '{change.PreviousValue}' 变为 '{change.CurrentValue}'," +
$"是否因线程切换:{change.ThreadContextChanged}");
});
这在调试 AsyncLocal 值的流转问题时非常有用。
3. 抑制 ExecutionContext 流转
极少数情况下,你想创建一个”断开继承”的子任务(不继承任何 AsyncLocal 值):
using (ExecutionContext.SuppressFlow())
{
// 这个 Task.Run 里读不到任何 AsyncLocal 的值
_ = Task.Run(() =>
{
Console.WriteLine(TraceContext.TraceId); // "N/A",因为流被切断了
});
}
99% 的业务代码不需要这个。主要用于后台任务,不希望它”意外继承”请求上下文而导致对象被意外保活(防止内存泄漏)。
4. 性能开销要了解
- 读取(
.Valueget):遍历IAsyncLocalValueMap,O(1) 到 O(n),n 是 AsyncLocal 实例数量。通常几个实例时可忽略不计。 - 写入(
.Valueset):创建新ExecutionContext和新IAsyncLocalValueMap,有分配开销。在热路径上(比如每毫秒数千次调用)需要注意,一般业务代码无需担心。 - await:每次
await触发ExecutionContext.Capture(),有轻微开销。ASP.NET Core 内部有大量针对性优化。
最佳实践:AsyncLocal 实例数量保持在个位数;存储轻量数据(ID、枚举、标志位)而非大对象。
内存泄漏:ThreadLocal 未释放的代价
ThreadLocal<T> 实现了 IDisposable,如果你忘记调用 Dispose(),会有内存泄漏风险。
原因:ThreadLocal<T> 在内部维护了一张全局的 WeakReference 表,用来追踪所有活跃的 ThreadLocal 实例。如果不 Dispose,这个条目永远不会被清理,而每个线程为它分配的本地数据也就无法被 GC 回收。
// 泄漏:static ThreadLocal 永远不 Dispose 是常见错误
// (虽然对 static 字段来说进程退出时会清理,但在长寿进程里仍有问题)
// 短生命周期的 ThreadLocal 要用 using
using var tl = new ThreadLocal<byte[]>(() => new byte[1024]);
// ... 用完自动 Dispose
对于 static readonly ThreadLocal,由于其生命周期跟随应用,通常问题不大;但如果你在某个方法/类内部创建了 ThreadLocal 实例,务必配合 IDisposable 模式释放。
ThreadLocal vs AsyncLocal:选哪个?
这张表你可以收藏,以后拿出来对照选型:
| 维度 | ThreadLocal<T> |
AsyncLocal<T> |
|---|---|---|
| 绑定对象 | 线程(Thread) | 执行上下文(ExecutionContext) |
| await 后值保持 | 可能丢失(切线程了) | 始终保持 |
| 子任务可见 | 不可见 | 可继承 |
| 子任务修改隔离 | N/A | 写时复制,完全隔离 |
| 线程池复用问题 | ️ 需手动清理 | 自动隔离 |
| 适用场景 | CPU 密集型多线程 | 异步调用链上下文 |
| 典型用例 | Random/StringBuilder 缓存、无锁计数器 | TraceId/UserId/TenantId |
| 需要 Dispose | 需要手动释放 | 无需 |
| 性能 | 极高(直接内存寻址) | 略低(ExecutionContext 查找) |
一句话记住:
- CPU 密集型、纯多线程、需要每线程独享实例 →
ThreadLocal<T>- 有 await 的异步代码、需要跨 await 传递上下文 →
AsyncLocal<T>
本章小结
今天我们从古老的 [ThreadStatic] 出发,走过了 ThreadLocal<T> 的工厂初始化、trackAllValues 追踪、线程池复用陷阱,然后重点深入了 AsyncLocal<T> 的:
- ExecutionContext 原理:每次
await都会携带 EC 快照流转,值不依赖具体线程 - 源码级理解:
Valueset 每次创建新ExecutionContext,IAsyncLocalValueMap是不可变结构 - 三条核心规则:向下继承(浅拷贝快照传递)、向上隔离(父 EC 永不被子修改)、写时复制(改变时创建新副本)
- 历史 Bug:.NET 6 之前值类型存储问题,及防御性写法
- 生产实战一:TraceMiddleware +
TraceContext全链路请求追踪 - 生产实战二:
CurrentUser静态上下文,彻底告别IHttpContextAccessor注入模式
几个关键结论:
[ThreadStatic]永远不要在声明时赋初值,子线程拿到的是默认值。ThreadLocal<T>在Task.Run里用,必须try/finally清理,否则线程复用会带来脏数据。AsyncLocal<T>的值存在ExecutionContext里,随异步调用链流转,与线程无关。- 三条规则熟记:向下继承、向上隔离、写时复制——这三条决定了你用它时所有的行为。
.NET 6之前避免存值类型,.NET 6+ 完全没问题。CurrentUser模式比IHttpContextAccessor注入更优雅,写时复制保证了并发请求的完全隔离。
下一篇我们将进入《无锁编程与内存模型》,探索 Interlocked、volatile、CAS 这些不用锁就能实现线程安全的底层武器——期待与你继续!
速查卡片
ThreadLocal<T>
├── 访问:.Value
├── 初始化:构造时传入工厂函数 () => new T()
├── 追踪所有线程值:trackAllValues: true → .Values
├── 必须 Dispose:using var tl = new ThreadLocal<T>(...);
└── 线程池用法:try { ... } finally { tl.Value = null; }
AsyncLocal<T>
├── 访问:.Value(get/set)
├── 传播方向:父 → 子(可见),子 → 父(隔离)
├── 底层机制:ExecutionContext 流转(每次 await 自动复制快照)
├── 引用类型陷阱:修改对象属性仍然共享,需要重新赋值新实例
└── 不需要 Dispose
选型口诀:
有 await → AsyncLocal
无 await,纯多线程 → ThreadLocal
参考资料
- ThreadLocal<T> Class – Microsoft Docs
- AsyncLocal<T> Class – Microsoft Docs
- ExecutionContext – Microsoft Docs
- ExecutionContext vs SynchronizationContext
文章摘自:https://www.cnblogs.com/diamondhusky/p/20241817
