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

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 这个静态字段初始化,只在类型被加载时执行一次,而类型加载发生在主线程。子线程拿到的是该字段的 默认值int0,引用类型是 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 对 ExecutionContextIAsyncLocalValueMap 的内部实现做了彻底重构,引入了新的 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,每个都要这样写

这有几个问题:

  1. 每个 Service 都要多一个无聊的构造函数参数,纯粹是样板代码
  2. Service 层被迫依赖 HTTP 基础设施IHttpContextAccessor 本质是对 HttpContext 的封装),破坏了分层架构的纯净性
  3. 单元测试麻烦:测试时需要 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. 性能开销要了解

  • 读取.Value get):遍历 IAsyncLocalValueMap,O(1) 到 O(n),n 是 AsyncLocal 实例数量。通常几个实例时可忽略不计。
  • 写入.Value set):创建新 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 快照流转,值不依赖具体线程
  • 源码级理解Value set 每次创建新 ExecutionContextIAsyncLocalValueMap 是不可变结构
  • 三条核心规则:向下继承(浅拷贝快照传递)、向上隔离(父 EC 永不被子修改)、写时复制(改变时创建新副本)
  • 历史 Bug:.NET 6 之前值类型存储问题,及防御性写法
  • 生产实战一:TraceMiddleware + TraceContext 全链路请求追踪
  • 生产实战二CurrentUser 静态上下文,彻底告别 IHttpContextAccessor 注入模式

几个关键结论:

  1. [ThreadStatic] 永远不要在声明时赋初值,子线程拿到的是默认值。
  2. ThreadLocal<T>Task.Run 里用,必须 try/finally 清理,否则线程复用会带来脏数据。
  3. AsyncLocal<T> 的值存在 ExecutionContext,随异步调用链流转,与线程无关。
  4. 三条规则熟记:向下继承、向上隔离、写时复制——这三条决定了你用它时所有的行为。
  5. .NET 6 之前避免存值类型,.NET 6+ 完全没问题。
  6. CurrentUser 模式IHttpContextAccessor 注入更优雅,写时复制保证了并发请求的完全隔离。

下一篇我们将进入《无锁编程与内存模型》,探索 Interlockedvolatile、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

参考资料

文章摘自:https://www.cnblogs.com/diamondhusky/p/20241817