【.NET并发编程 - 13】ThreadLocal 与 AsyncLocal:线程当地存储

[复制链接]
发表于 2026-5-31 19:55:47 | 显示全部楼层 |阅读模式

马上注册,结交更多好友,享用更多功能,让你轻松玩转社区。

您需要 登录 才可以下载或查看,没有账号?立即注册

×
13. ThreadLocal 与 AsyncLocal:线程当地存储

本章 GitHub 堆栈:csharp-concurrency-cookbook ⭐
欢迎 Star 和 Fork!全部代码示例都可以在堆栈中找到并运行。
🎯 本章导读

📌 本文目的:搞清楚 ThreadLocal 和 AsyncLocal 各自办理什么标题;深入 AsyncLocal 的 ExecutionContext 原理与源码机制,彻底明白"向下继承、向上隔离、写时复制"三条核心规则;以及怎样在 ASP.NET Core 中用 AsyncLocal 实现生产级 TraceId 追踪和优雅的 CurrentUser 全局上下文,彻底告别 IHttpContextAccessor 注入的样板代码。
上一篇《并发聚集与线程安全范例》里,我们聊的是"多线程共享同一份数据"的标题。本日翻个方向,聊聊每个线程(或每条异步调用链)独占本身一份数据的标题——这就是"线程当地存储(Thread-Local Storage,TLS)"。
先来个灵魂拷问:你有没有碰到过这种需求?
"我有一个 Random 实例,我想多线程并发用它天生随机数,但 Random 不是线程安全的,加锁又太慢——怎么办?"
大概:
"我在处置处罚 HTTP 哀求,我想让 TraceId(哀求追踪 ID)在整个调用链里都能取到,但我又不想把它一层一层地当参数传下去——怎么搞?"
这两个标题,分别是 ThreadLocal 和 AsyncLocal 的经典使用场景。学完这篇,这两个标题你都能给出美丽的解答。
🧵 从最古老的方式提及:[ThreadStatic]

在 ThreadLocal 出现之前(.NET 4.0 从前),我们用 [ThreadStatic] 特性来实现线程当地存储。
  1. [ThreadStatic]
  2. private static int _counter = 10;
复制代码
[ThreadStatic] 的作用很简朴:让每个线程都拥有这个静态字段的独立副本。线程A改了它,不影响线程B。
听起来很精美,但有一个经典陷阱——
⚠️ [ThreadStatic] 的初始化陷阱
  1. [ThreadStatic]
  2. private static int _counter = 10;  // ❌ 伤害!var t = new Thread(() =>{        Console.WriteLine(_counter); // 输出 0,不是 10!});t.Start();t.Join();
复制代码
为什么子线程拿到的是 0 而不是 10?
由于 = 10 这个静态字段初始化,只在范例被加载时实行一次,而范例加载发生在主线程。子线程拿到的是该字段的 默认值(int 是 0,引用范例是 null)。
精确做法:声明时不赋初值,在每个线程开始时手动初始化:
  1. [ThreadStatic]
  2. private static int _counter;  // ✅ 不赋初值
  3. // 在线程入口处手动初始化
  4. _counter = 10;
复制代码
[ThreadStatic] 另有另一个限定:只能用在静态字段上,不能用于属性、局部变量、实例字段。
📦 ThreadLocal:[ThreadStatic] 的进化版

.NET 4.0 带来了 ThreadLocal,把上面谁人"初始化陷阱"彻底办理了,同时增长了更多实用特性。
根本用法:工厂函数初始化
  1. // ✅ 通过工厂函数初始化,每个线程第一次访问 .Value 时执行一次
  2. private static readonly ThreadLocal<StringBuilder> _sb =
  3.         new(() => new StringBuilder());
复制代码
工厂函数是懒加载的——第一次访问 .Value 才实行,而且每个线程的工厂函数调用是相互独立的。
  1. var threads = Enumerable.Range(1, 3).Select(i => new Thread(() =>
  2. {
  3.         // 每个线程都有自己独立的 StringBuilder 实例
  4.         _sb.Value!.Append($"来自线程{Thread.CurrentThread.ManagedThreadId}的消息");
  5.         Console.WriteLine(_sb.Value);
  6. })).ToList();
  7. threads.ForEach(t => t.Start());
  8. threads.ForEach(t => t.Join());
  9. // 记得释放!
  10. _sb.Dispose();
复制代码
ThreadLocal 对比 [ThreadStatic]

特性[ThreadStatic]ThreadLocal初始化方式只有主线程实行静态初始化✅ 每线程独立的工厂函数实用范围只能用于静态字段可以是恣意成员追踪全部线程的值❌ 不支持✅ trackAllValues: true实现 IDisposable❌✅ 必要手动 Dispose延长初始化❌✅trackAllValues:追踪全部线程的值

这是一个低调但好用的特性,恰当"无锁多线程统计"的场景:
  1. // trackAllValues: true —— 可以通过 .Values 拿到所有线程的值快照
  2. using var perThreadCount = new ThreadLocal<int>(
  3.         valueFactory: () => 0,
  4.         trackAllValues: true);
  5. var threads = Enumerable.Range(1, 5).Select(i => new Thread(() =>
  6. {
  7.         perThreadCount.Value = i * 10;  // 每个线程自己计数
  8. })).ToList();
  9. threads.ForEach(t => t.Start());
  10. threads.ForEach(t => t.Join());
  11. // 汇总所有线程的计数,无需任何锁!
  12. var total = perThreadCount.Values.Sum();
  13. Console.WriteLine($"总计:{total}");
复制代码
这比用 lock 掩护的全局计数器服从高很多,由于各线程写入时完全无竞争,只有末了汇总时才必要一次聚合。
⚠️ ThreadLocal 在线程池中的"复用陷阱"

这是一个很多人踩过的坑,务必记清楚。
线程池里的线程是复用的——任务完成后线程不会烧毁,而是回到池子里等下一个任务。如果你在 Task.Run 里设置了 ThreadLocal 却忘记清算,下一个跑在同一个线程上的任务就会读到脏数据:
  1. private static readonly ThreadLocal<string?> _requestContext = new(() => null);
  2. // ❌ 危险写法
  3. var task1 = Task.Run(() =>
  4. {
  5.         _requestContext.Value = "RequestId=AAA";
  6.         // ... 处理完,但忘了清理
  7. });
  8. await task1;
  9. // 如果 task2 分到同一个线程,会读到 "RequestId=AAA"!
  10. var task2 = Task.Run(() =>
  11. {
  12.         Console.WriteLine(_requestContext.Value);  // 可能是 "RequestId=AAA",数据污染!
  13. });
  14. await task2;
复制代码
精确做法:try/finally 包管清算:
  1. // ✅ 正确写法
  2. var task = Task.Run(() =>
  3. {
  4.         try
  5.         {
  6.                 _requestContext.Value = "RequestId=BBB";
  7.                 // 处理业务逻辑
  8.         }
  9.         finally
  10.         {
  11.                 _requestContext.Value = null;  // ✅ 无论异常与否,都清理
  12.         }
  13. });
复制代码
💬 说真的,这个坑让很多人调试了好几个小时才找到缘故起因——线程池里的线程数目有限,一旦触发复用,标题就很难复现。只要在 Task.Run 里用了 ThreadLocal,就要养成写 try/finally 的风俗。
🎯 ThreadLocal 实战:线程安全的 Random

这是 ThreadLocal 最经典的使用场景之一。System.Random 不是线程安全的,在老版本 .NET 中多线程共享一个 Random 会导致它内部状态粉碎,全部后续调用只返回 0(经典 bug)。
  1. // ✅ 每线程一个 Random 实例,完全无竞争
  2. private static readonly ThreadLocal<Random> _random =
  3.         new(() => new Random(Thread.CurrentThread.ManagedThreadId * 31 + Environment.TickCount));
  4. // 在任意线程中安全使用
  5. var value = _random.Value!.Next(1, 100);
复制代码
📝 .NET 6+ 注意:Random.Shared 已经是官方线程安全实现,新项目直接用 Random.Shared 就行,不必要 ThreadLocal 了。但明白这个模式对于维护老代码非常告急。
🌊 AsyncLocal:异步天下里的"上下文传播"

好,现在我们进入本日的重头戏——AsyncLocal。
如果说 ThreadLocal 是"跟踪线程",那么 AsyncLocal 是跟踪逻辑实行流(异步调用链)。
先看一个标题:在 async 方法里设置了 ThreadLocal 值,await 之后还在吗?
  1. private static readonly ThreadLocal<string?> _tl = new(() => null);
  2. async Task TestAsync()
  3. {
  4.         _tl.Value = "hello";
  5.         Console.WriteLine(_tl.Value);  // "hello"  ✅
  6.         await Task.Delay(100);  // await 可能切换到另一个线程!
  7.         Console.WriteLine(_tl.Value);  // 可能是 null  ❌
  8. }
复制代码
await 之后,运行时大概把你调理到另一个线程上继承实行,而谁人线程的 ThreadLocal 副本是空的——值就"找不到"了。
AsyncLocal 就是来办理这个标题的:
  1. private static readonly AsyncLocal<string?> _al = new();
  2. async Task TestAsync()
  3. {
  4.         _al.Value = "hello";
  5.         await Task.Delay(100);  // 无论切没切线程
  6.         Console.WriteLine(_al.Value);  // ✅ 依然是 "hello"
  7. }
复制代码
🔬 深入原理:ExecutionContext 是怎样工作的?

要真正明白 AsyncLocal,必须先搞清楚 ExecutionContext ——这才是幕后的真正主角。
ExecutionContext 是什么?

ExecutionContext 是 .NET 运行时用来转达"实行环境"的容器,你可以把它想象成一个随着异步调用链主动活动的"背包":线程切换了、await 了、Task.Run 了,这个背包都会跟着走。
AsyncLocal 的值,就存在这个背包里。
源码结构速览(.NET 10)

我们来看看 .NET 运行时的现实源码(coreclr/ExecutionContext.cs):
  1. // ExecutionContext 的核心字段(精简版)
  2. public sealed class ExecutionContext
  3. {
  4.         // AsyncLocal 的值就存在这个不可变映射里
  5.         // (是一个经过优化的不可变 AsyncLocal 键值对映射)
  6.         internal IAsyncLocalValueMap? m_localValues;
  7.         // 快照复制(这是"写时复制"的关键)
  8.         internal static ExecutionContext ShallowClone(ExecutionContext? ec)
  9.         {
  10.                 return new ExecutionContext
  11.                 {
  12.                         m_localValues = ec?.m_localValues  // 直接共享同一个 map 引用(浅拷贝!)
  13.                 };
  14.         }
  15. }
复制代码
再看 AsyncLocal.Value 的 set 实现:
  1. // AsyncLocal<T>.Value 的 setter(精简版)
  2. public T? Value
  3. {
  4.         set
  5.         {
  6.                 // 1. 获取当前线程的 ExecutionContext
  7.                 var current = Thread.CurrentThread._executionContext;
  8.                 // 2. 用新值创建一个新的 IAsyncLocalValueMap(不可变结构,写入 = 生成新副本)
  9.                 var newValues = AsyncLocalValueMap.Create(this, value, current?.m_localValues);
  10.                 // 3. 把新的 map 包装成一个新的 ExecutionContext,赋给当前线程
  11.                 //    ⚠️ 注意:这是一个全新的 ExecutionContext 对象,不是修改原来那个!
  12.                 Thread.CurrentThread._executionContext = new ExecutionContext { m_localValues = newValues };
  13.                 // 4. 如果注册了值变更回调(ValueChanged),通知它
  14.                 if (_valueChangedHandler != null)
  15.                         _valueChangedHandler(new AsyncLocalValueChangedArgs<T>(...));
  16.         }
  17. }
复制代码
这几行代码展现了最核心的机制:每次给 AsyncLocal.Value 赋值,都会天生一个全新的 ExecutionContext 对象,老的谁人完全不受影响。这就是"写时复制"的本质。
await 时发生了什么?

每次 await 一个 Task,编译器天生的状态机遇如许处置处罚(简化版):
  1. // 编译器生成的状态机(概念代码)
  2. void MoveNext()
  3. {
  4.         // ... 进入 await 点 ...
  5.         // 捕获当前 ExecutionContext 的快照
  6.         var capturedContext = ExecutionContext.Capture();
  7.         // 把"恢复执行"这个动作(continuation)包装好,连同 ExecutionContext 一起投递给线程池
  8.         ThreadPool.QueueUserWorkItem(state =>
  9.         {
  10.                 // 线程池线程上:用捕获的 ExecutionContext 恢复执行
  11.                 ExecutionContext.Run(capturedContext, s => continuation(s), state);
  12.         }, null);
  13. }
复制代码
ExecutionContext.Capture() 拿到的是当前上下文的快照(浅拷贝),ExecutionContext.Run() 则在指定上下文下实行 continuation。整个过程 AsyncLocal 的值都随着这个快照流转,不依靠详细是哪个线程。
🎯 核心规则图解:向下继承 · 向上隔离 · 写时复制

这三条规则是 AsyncLocal 的灵魂,搞清楚了就彻底把握了它。
规则一:向下继承(Downward Propagation)

父级设置的值,子级(子方法、子 Task)默认可以读到。
flowchart TD        Root["Root
TraceId = 'REQ-001'"]        ChildA["ChildA 进入
读到 TraceId = 'REQ-001' ✅"]        ChildB["ChildB 进入
读到 TraceId = 'REQ-001' ✅"]        Root -->|"await / Task.Run
EC 快照转达(浅拷贝)"| ChildA        Root -->|"await / Task.Run
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
TraceId = 'REQ-001'"]        ChildA["ChildA
将 TraceId 改为 'CHILD-A'
(创建新 ExecutionContext)"]        RootAfter["Root await 返回后
TraceId 仍然是 'REQ-001' ✅"]        Root -->|"await ChildA()
EC 快照转达"| ChildA        ChildA -->|"return(子 EC 殒命)
父 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
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:2pxChildA 实行 _traceId.Value = "CHILD-A"——写时复制,创建全新 EC,父流 EC 完全不受影响:
flowchart LR        RootFlow(["Root 流"])        ChildFlow(["ChildA 流"])        subgraph ECRoot["EC-Root — 未修改"]                mvRoot["m_localValues
TraceId = 'REQ-001'"]        end        subgraph ECChild["EC-ChildA — 新创建"]                mvChild["m_localValues
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:2pxIAsyncLocalValueMap 是一个不可变(immutable)的键值映射,每次写入都会天生新的映射实例,旧的映射稳定。这就是为什么父流永久看不到子流的修改。
完备时序图

sequenceDiagram        participant Root as 🔵 Root        participant ChildA as 🟠 ChildA        participant ChildB as 🟢 ChildB        Root->>Root: _ctx.Value = "REQ"
(EC: TraceId="REQ")        Root->>ChildA: await ChildA()
转达 EC 快照(TraceId="REQ")        ChildA->>ChildA: 读 TraceId = "REQ" ✅(向下继承)        ChildA->>ChildA: _ctx.Value = "CHILD"
(创建新 EC,TraceId="CHILD")        ChildA->>ChildB: await ChildB()
转达 EC 快照(TraceId="CHILD")        ChildB->>ChildB: 读 TraceId = "CHILD" ✅(继承自 ChildA)        Note over ChildB: await Task.Delay → 大概切换线程        ChildB->>ChildB: 切换后读 TraceId = "CHILD" ✅
(EC 随 continuation 流转,与线程无关)        ChildB-->>ChildA: return        ChildA-->>Root: return(ChildA 的新 EC 随栈帧殒命)        Root->>Root: 读 TraceId = "REQ" ✅(向上隔离)
Root 的 EC 从未被修改📝 用代码验证三条规则
  1. private static readonly AsyncLocal<string?> _ctx = new();
  2. async Task VerifyRulesAsync()
  3. {
  4.         // ── 规则1:向下继承 ──────────────────────────────────
  5.         _ctx.Value = "Root";
  6.         await Task.Run(async () =>
  7.         {
  8.                 Console.WriteLine(_ctx.Value); // "Root" ✅ 子任务继承了父级的值
  9.                 await Task.Delay(1);
  10.                 Console.WriteLine(_ctx.Value); // "Root" ✅ await 后依然保持
  11.         });
  12.         // ── 规则2:向上隔离 ──────────────────────────────────
  13.         await ModifyInChildAsync();
  14.         Console.WriteLine(_ctx.Value); // "Root" ✅ 子方法修改不影响父级
  15.         // ── 规则3:写时复制(两个并发分支互不干扰)──────────
  16.         var t1 = ProcessAsync("Branch1", "B1");
  17.         var t2 = ProcessAsync("Branch2", "B2");
  18.         await Task.WhenAll(t1, t2);
  19.         Console.WriteLine(_ctx.Value); // "Root" ✅ 并发分支的修改都不影响主流
  20. }
  21. async Task ModifyInChildAsync()
  22. {
  23.         Console.WriteLine(_ctx.Value); // "Root" ✅ 可以读到父级的值
  24.         _ctx.Value = "Child";          // 写时复制,创建新 EC
  25.         Console.WriteLine(_ctx.Value); // "Child" ✅
  26.         await Task.Delay(1);
  27.         Console.WriteLine(_ctx.Value); // "Child" ✅ await 后自己的副本还在
  28. }   // 方法返回后,这个新 EC 随着栈帧消亡,父级的 EC 完全不受影响
  29. async Task ProcessAsync(string name, string value)
  30. {
  31.         _ctx.Value = value;            // 每个分支都有自己独立的 EC 副本
  32.         await Task.Delay(10);
  33.         Console.WriteLine($"[{name}] {_ctx.Value}"); // 各自正确,互不干扰
  34. }
复制代码
🐛 汗青 Bug:.NET 6 之前值范例的陷阱

这是一个鲜少被提及但很真实的汗青标题,如果你维护老项目,务必相识。
在 .NET 6 之前,AsyncLocal 在存储值范例(struct) 时存在一个埋伏的标题:由于 IAsyncLocalValueMap 的实现方式,值范例会被装箱(Boxing)存储。这本身不是 bug,但有一个棘手的副作用:
标题复现(.NET Framework / .NET 5 及以下):
  1. // 假设 T 是值类型 int
  2. var al = new AsyncLocal<int>();
  3. al.Value = 42;
  4. await Task.Run(() =>
  5. {
  6.         // 在某些旧版本运行时中,如果没有赋值(只是读),
  7.         // 对 ExecutionContext 的其他修改(比如设置了另一个 AsyncLocal)
  8.         // 可能触发 EC 重建,导致值类型副本的引用断开,
  9.         // 下次读取时拿到的是默认值 0 而不是 42
  10.         // (具体触发条件依赖运行时版本和 EC 的内部实现细节)
  11.         Console.WriteLine(al.Value); // 在受影响版本上:0(而不是 42)
  12. });
复制代码
根本缘故起因:旧版 IAsyncLocalValueMap 在某些 slot 优化路径上,对值范例的处置处罚与引用范例差别等,导致在 EC 快照复制时,值范例的 box 实例没有被精确转达。
办理方案(.NET 6 之前的防御性写法):
  1. // ✅ 把值类型包装成引用类型(class),彻底避免问题
  2. private static readonly AsyncLocal<IntWrapper?> _count = new();
  3. class IntWrapper { public int Value { get; set; } }
  4. // 最保险:用 string 传数字,完全避开结构体装箱问题
  5. private static readonly AsyncLocal<string?> _countStr = new();
复制代码
.NET 6 的修复:.NET 6 对 ExecutionContext 和 IAsyncLocalValueMap 的内部实现做了彻底重构,引入了新的 AsyncLocalValueMap 实现,精确处置处罚了值范例的存储和转达。从 .NET 6 起,AsyncLocal、AsyncLocal 等值范例用法是完全安全的。
💡 项目用的是 .NET 10,这个 bug 不存在。但如果你接办 .NET 5 或更早的老项目,看到有人把 AsyncLocal 包装成 AsyncLocal 时,现在你知道缘故起因了。
🏢 实战一:用 AsyncLocal 实现生产级 TraceId 追踪

下面是完备的生产级实现(代码见 AsyncLocalDemo 项目)。
架构计划

flowchart TD    Request(["HTTP 哀求
(可携带 X-Trace-Id 哀求头)"])    subgraph MW["ASP.NET Core 中心件管道"]        direction TB        TM["TraceMiddleware
① 读取 / 天生 TraceId
② TraceContext.Initialize → 写入 AsyncLocal
③ 相应头写回 X-Trace-Id"]        Auth["UseAuthentication
JWT 验证 → 添补 HttpContext.User"]        CUM["CurrentUserMiddleware
从 Claims 剖析用户信息
CurrentUser.Set → 写入 AsyncLocal"]        AuthZ["UseAuthorization"]    end    subgraph BIZ["业务层(恣意层直接读取 AsyncLocal,无需注入)"]        direction TB        Ctrl["Controller
TraceContext.TraceId / CurrentUser.UserId"]        Svc["Service
TraceContext.TraceId / CurrentUser.UserId"]        Repo["Repository
TraceContext.TraceId"]    end    Finally["TraceMiddleware finally 块
记录哀求总耗时日记"]    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:1pxTraceContext 静态类
  1. public static class TraceContext
  2. {
  3.         // 使用 AsyncLocal<string?> 存储 TraceId
  4.         private static readonly AsyncLocal<string?> _traceId = new();
  5.         private static readonly AsyncLocal<DateTime> _requestStartTime = new();
  6.         public static string TraceId => _traceId.Value ?? "N/A";
  7.         public static DateTime RequestStartTime => _requestStartTime.Value;
  8.         public static double ElapsedMs => (DateTime.UtcNow - _requestStartTime.Value).TotalMilliseconds;
  9.         // internal:只允许 TraceMiddleware 写入,业务代码只读
  10.         internal static void Initialize(string? traceId = null)
  11.         {
  12.                 _traceId.Value = traceId ?? Guid.NewGuid().ToString("N")[..16].ToUpper();
  13.                 _requestStartTime.Value = DateTime.UtcNow;
  14.         }
  15. }
复制代码
TraceMiddleware
  1. public sealed class TraceMiddleware(RequestDelegate next, ILogger<TraceMiddleware> logger)
  2. {
  3.         private const string TraceIdHeader = "X-Trace-Id";
  4.         public async Task InvokeAsync(HttpContext context)
  5.         {
  6.                 // 1. 从请求头读取(支持链路追踪透传),否则自动生成
  7.                 var incomingTraceId = context.Request.Headers[TraceIdHeader].FirstOrDefault();
  8.                 TraceContext.Initialize(incomingTraceId);
  9.                 // 2. 响应头写回 TraceId
  10.                 context.Response.OnStarting(() =>
  11.                 {
  12.                         context.Response.Headers[TraceIdHeader] = TraceContext.TraceId;
  13.                         return Task.CompletedTask;
  14.                 });
  15.                 // 3. ILogger Scope 让整个请求的所有日志日志自动携带 TraceId
  16.                 using (logger.BeginScope(new Dictionary<string, object> { ["TraceId"] = TraceContext.TraceId }))
  17.                 {
  18.                         logger.LogInformation("请求开始 [{Method}] {Path}", context.Request.Method, context.Request.Path);
  19.                         try
  20.                         {
  21.                                 await next(context);
  22.                         }
  23.                         finally
  24.                         {
  25.                                 logger.LogInformation(
  26.                                         "请求结束 StatusCode={StatusCode} Elapsed={Elapsed:F1}ms",
  27.                                         context.Response.StatusCode, TraceContext.ElapsedMs);
  28.                         }
  29.                 }
  30.         }
  31. }
复制代码
Service 层:零注入读取上下文
  1. public sealed class OrderService(ILogger<OrderService> logger) : IOrderService
  2. {
  3.         public async Task<IReadOnlyList<OrderDto>> GetMyOrdersAsync(CancellationToken ct = default)
  4.         {
  5.                 // ✅ 直接读取 AsyncLocal 上下文,无需 IHttpContextAccessor 注入
  6.                 var userId = CurrentUser.UserId;
  7.                 var traceId = TraceContext.TraceId;
  8.                 logger.LogInformation("[{TraceId}] GetMyOrders: 查询用户 {UserId}", traceId, userId);
  9.                 await Task.Delay(10, ct);  // 模拟数据库 I/O
  10.                 // ✅ await 之后,TraceId 和 UserId 依然有效(ExecutionContext 流转保证)
  11.                 logger.LogInformation("[{TraceId}] GetMyOrders 完成", TraceContext.TraceId);
  12.                 return _db.Where(o => o.UserId == userId).ToList();
  13.         }
  14. }
复制代码
Program.cs 中心件注册次序
  1. // ✅ 第一步:TraceMiddleware 放在最前端
  2. app.UseTracing();
  3. app.UseAuthentication();  // JWT 认证,填充 HttpContext.User
  4. // ✅ 第二步:CurrentUserMiddleware 必须在 UseAuthentication 之后
  5. app.UseCurrentUser();
  6. app.UseAuthorization();
  7. app.MapControllers();
复制代码
🧑‍💼 实战二:用 AsyncLocal 实现 CurrentUser,彻底告别 IHttpContextAccessor

这是我以为 AsyncLocal 最能体现代价的实战场景,办理的是一个让无数团队头疼的工程痛点。
原来的做法有什么标题?
  1. // ❌ 传统写法:每个 Service 都注入 IHttpContextAccessor
  2. // ServiceBase 基类
  3. public abstract class ServiceBase
  4. {
  5.         protected readonly IHttpContextAccessor _httpContextAccessor;
  6.         protected ServiceBase(IHttpContextAccessor httpContextAccessor)
  7.                 => _httpContextAccessor = httpContextAccessor;
  8.         protected string? CurrentUserId =>
  9.                 _httpContextAccessor.HttpContext?.User?.FindFirstValue(ClaimTypes.NameIdentifier);
  10. }
  11. // 每个具体 Service 都得写这个构造函数
  12. public class OrderService : ServiceBase
  13. {
  14.         public OrderService(IHttpContextAccessor httpContextAccessor,
  15.                                                 IRepository<Order> repo,
  16.                                                 ILogger<OrderService> logger)
  17.                 : base(httpContextAccessor) { ... }
  18. }
  19. public class ProductService : ServiceBase
  20. {
  21.         public ProductService(IHttpContextAccessor httpContextAccessor,
  22.                                                   IRepository<Product> repo)
  23.                 : base(httpContextAccessor) { ... }
  24. }
  25. // ... 几十个 Service,每个都要这样写
复制代码
这有几个标题:

  • 每个 Service 都要多一个无聊的构造函数参数,纯粹是样板代码
  • Service 层被迫依靠 HTTP 根本办法(IHttpContextAccessor 本质是对 HttpContext 的封装),粉碎了分层架构的纯净性
  • 单位测试贫苦:测试时必要 Mock IHttpContextAccessor,设置繁琐
用 AsyncLocal 的新方案
  1. // ✅ 新方案:CurrentUser 全局静态上下文
  2. public static class CurrentUser
  3. {
  4.         private static readonly AsyncLocal<UserInfo?> _userInfo = new();
  5.         public static UserInfo? Info    => _userInfo.Value;
  6.         public static string?  UserId  => _userInfo.Value?.UserId;
  7.         public static string   UserName => _userInfo.Value?.UserName ?? "Anonymous";
  8.         public static bool     IsAuthenticated => _userInfo.Value?.IsAuthenticated == true;
  9.         public static bool     IsInRole(string role) => _userInfo.Value?.IsInRole(role) == true;
  10.         // 仅供 CurrentUserMiddleware 调用
  11.         internal static void Set(ClaimsPrincipal? principal) { ... }
  12.         // 仅供单元测试使用
  13.         public static void SetForTesting(UserInfo? userInfo) => _userInfo.Value = userInfo;
  14. }
复制代码
  1. // CurrentUserMiddleware:认证完成后,从 Claims 填充 CurrentUser
  2. public sealed class CurrentUserMiddleware(RequestDelegate next)
  3. {
  4.         public async Task InvokeAsync(HttpContext context)
  5.         {
  6.                 // UseAuthentication 已经运行完毕,HttpContext.User 已填充
  7.                 CurrentUser.Set(context.User);
  8.                 await next(context);
  9.         }
  10. }
复制代码
  1. // ✅ 新的 Service:构造函数干净了!
  2. public class OrderService(ILogger<OrderService> logger) : IOrderService
  3. {
  4.         public async Task<OrderDto> CreateOrderAsync(string productName, decimal amount)
  5.         {
  6.                 // 直接读取,零注入
  7.                 var userId = CurrentUser.UserId ?? throw new UnauthorizedAccessException();
  8.                 var userName = CurrentUser.UserName;
  9.                 // ...
  10.         }
  11. }
  12. public class ProductService(IRepository<Product> repo) : IProductService
  13. {
  14.         // 同样干净,不需要注入 IHttpContextAccessor
  15. }
复制代码
为什么如许是线程安全的?

有人大概会担心:CurrentUser 是静态类,多个并发哀求会不会相互串号?
不会。AsyncLocal 的写时复制语义包管了每个哀求有独立的 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)
写入 AsyncLocal → 创建新 EC1
(仅影响哀求1的实行流)        MW2->>MW2: CurrentUser.Set(Bob)
写入 AsyncLocal → 创建新 EC2
(仅影响哀求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:
  1. [Fact]
  2. public async Task CreateOrder_ShouldReturnOrder_WhenUserAuthenticated()
  3. {
  4.         // Arrange
  5.         CurrentUser.SetForTesting(new UserInfo
  6.         {
  7.                 UserId = "test-user-001",
  8.                 UserName = "TestUser",
  9.                 Email = "[email protected]",
  10.                 Roles = ["User"]
  11.         });
  12.         var service = new OrderService(Mock.Of<ILogger<OrderService>>());
  13.         // Act
  14.         var order = await service.CreateOrderAsync("MacBook Pro", 12999m);
  15.         // Assert
  16.         Assert.Equal("test-user-001", order.UserId);
  17. }
复制代码
⚠️ 使用 AsyncLocal 的注意事项

1. 引用范例的"浅拷贝"陷阱

AsyncLocal 的写时复制是对 Value 引用本身的复制,而不是对象的深拷贝!
  1. // ❌ 陷阱:T 是 List<string>(引用类型)
  2. var myList = new AsyncLocal<List<string>>();
  3. myList.Value = new List<string> { "初始值" };
  4. await Task.Run(() =>
  5. {
  6.         // 子任务没有给 myList.Value 重新赋值——
  7.         // 子任务的 EC 和父任务的 EC 指向同一个 List 实例!
  8.         myList.Value!.Add("子任务的数据");  // ⚠️ 这修改的是父任务看到的那个 List!
  9. });
  10. Console.WriteLine(myList.Value!.Count);  // 2,父任务的 List 被改了!
复制代码
精确做法:子任务写入时重新赋值新实例(触发写时复制):
  1. await Task.Run(() =>
  2. {
  3.         // ✅ 重新赋值 → 触发写时复制 → 创建新 EC → 父任务的 EC 不受影响
  4.         myList.Value = new List<string>(myList.Value!) { "子任务的数据" };
  5. });
复制代码
2. ValueChanged 回调

AsyncLocal 构造函数可以传入一个回调,在值被修改(包罗 EC 流转时)时触发:
  1. var al = new AsyncLocal<string?>(change =>
  2. {
  3.         Console.WriteLine($"值从 '{change.PreviousValue}' 变为 '{change.CurrentValue}'," +
  4.                                           $"是否因线程切换:{change.ThreadContextChanged}");
  5. });
复制代码
这在调试 AsyncLocal 值的流转标题时非常有用。
3. 克制 ExecutionContext 流转

少少数环境下,你想创建一个"断开继承"的子任务(不继承任何 AsyncLocal 值):
  1. using (ExecutionContext.SuppressFlow())
  2. {
  3.         // 这个 Task.Run 里读不到任何 AsyncLocal 的值
  4.         _ = Task.Run(() =>
  5.         {
  6.                 Console.WriteLine(TraceContext.TraceId);  // "N/A",因为流被切断了
  7.         });
  8. }
复制代码
99% 的业务代码不必要这个。告急用于背景任务,不渴望它"不测继承"哀求上下文而导致对象被不测保活(防止内存走漏)。
4. 性能开销要相识


  • 读取(.Value get):遍历 IAsyncLocalValueMap,O(1) 到 O(n),n 是 AsyncLocal 实例数目。通常几个实例时可忽略不计。
  • 写入(.Value set):创建新 ExecutionContext 和新 IAsyncLocalValueMap,有分配开销。在热路径上(好比每毫秒数千次调用)必要注意,一样平常业务代码无需担心。
  • await:每次 await 触发 ExecutionContext.Capture(),有轻微开销。ASP.NET Core 内部有大量针对性优化。
最佳实践:AsyncLocal 实例数目保持在个位数;存储轻量数据(ID、罗列、标志位)而非大对象。
🔴 内存走漏:ThreadLocal 未开释的代价

ThreadLocal 实现了 IDisposable,如果你忘记调用 Dispose(),会有内存走漏风险。
缘故起因:ThreadLocal 在内部维护了一张全局的 WeakReference 表,用来追踪全部生动的 ThreadLocal 实例。如果不 Dispose,这个条目永久不会被清算,而每个线程为它分配的当地数据也就无法被 GC 采取。
  1. // ❌ 泄漏:static ThreadLocal 永远不 Dispose 是常见错误
  2. // (虽然对 static 字段来说进程退出时会清理,但在长寿进程里仍有问题)
  3. // ✅ 短生命周期的 ThreadLocal 要用 using
  4. using var tl = new ThreadLocal<byte[]>(() => new byte[1024]);
  5. // ... 用完自动 Dispose
复制代码
对于 static readonly ThreadLocal,由于其生命周期跟随应用,通常标题不大;但如果你在某个方法/类内部创建了 ThreadLocal 实例,务必共同 IDisposable 模式开释。
📊 ThreadLocal vs AsyncLocal:选哪个?

这张表你可以收藏,以后拿出来对照选型:
维度ThreadLocalAsyncLocal绑定对象线程(Thread)实行上下文(ExecutionContext)await 后值保持❌ 大概丢失(切线程了)✅ 始终保持子任务可见❌ 不可见✅ 可继承子任务修改隔离N/A✅ 写时复制,完全隔离线程池复用标题⚠️ 需手动清算✅ 主动隔离实用场景CPU 麋集型多线程异步调用链上下文典范用例Random/StringBuilder 缓存、无锁计数器TraceId/UserId/TenantId必要 Dispose✅ 必要手动开释❌ 无需性能极高(直接内存寻址)略低(ExecutionContext 查找)一句话记着:


  • CPU 麋集型、纯多线程、必要每线程独享实例 → ThreadLocal
  • 有 await 的异步代码、必要跨 await 转达上下文 → AsyncLocal
🎓 本章小结

本日我们从古老的 [ThreadStatic] 出发,走过了 ThreadLocal 的工厂初始化、trackAllValues 追踪、线程池复用陷阱,然后重点深入了 AsyncLocal 的:

  • ExecutionContext 原理:每次 await 都会携带 EC 快照流转,值不依靠详细线程
  • 源码级明白:Value set 每次创建新 ExecutionContext,IAsyncLocalValueMap 是不可变结构
  • 三条核心规则:向下继承(浅拷贝快照转达)、向上隔离(父 EC 永不被子修改)、写时复制(改变时创建新副本)
  • 汗青 Bug:.NET 6 之前值范例存储标题,及防御性写法
  • 生产实战一:TraceMiddleware + TraceContext 全链路哀求追踪
  • 生产实战二:CurrentUser 静态上下文,彻底告别 IHttpContextAccessor 注入模式
几个关键结论:

  • [ThreadStatic] 永久不要在声明时赋初值,子线程拿到的是默认值。
  • ThreadLocal 在 Task.Run 里用,必须 try/finally 清算,否则线程复用会带来脏数据。
  • AsyncLocal 的值存在 ExecutionContext 里,随异步调用链流转,与线程无关。
  • 三条规则熟记:向下继承、向上隔离、写时复制——这三条决定了你用它时全部的举动。
  • .NET 6 之前制止存值范例,.NET 6+ 完全没标题。
  • CurrentUser 模式比 IHttpContextAccessor 注入更优雅,写时复制包管了并发哀求的完全隔离。
下一篇我们将进入《无锁编程与内存模子》,探索 Interlocked、volatile、CAS 这些不消锁就能实现线程安全的底层武器——等待与你继承!
⚡ 速查卡片
  1. ThreadLocal<T>
  2. ├── 访问:.Value
  3. ├── 初始化:构造时传入工厂函数 () => new T()
  4. ├── 追踪所有线程值:trackAllValues: true → .Values
  5. ├── 必须 Dispose:using var tl = new ThreadLocal<T>(...);
  6. └── 线程池用法:try { ... } finally { tl.Value = null; }
  7. AsyncLocal<T>
  8. ├── 访问:.Value(get/set)
  9. ├── 传播方向:父 → 子(可见),子 → 父(隔离)
  10. ├── 底层机制:ExecutionContext 流转(每次 await 自动复制快照)
  11. ├── 引用类型陷阱:修改对象属性仍然共享,需要重新赋值新实例
  12. └── 不需要 Dispose
  13. 选型口诀:
  14.   有 await → AsyncLocal
  15.   无 await,纯多线程 → ThreadLocal
复制代码
📚 参考资料


  • ThreadLocal Class - Microsoft Docs
  • AsyncLocal Class - Microsoft Docs
  • ExecutionContext - Microsoft Docs
  • ExecutionContext vs SynchronizationContext
回复

使用道具 举报

登录后关闭弹窗

登录参与点评抽奖  加入IT实名职场社区
去登录
快速回复 返回顶部 返回列表