C#.NET ThreadLocal 深入解析:线程独享数据、性能收益与实战边界
多线程代码里最麻烦的一个点,不是“怎么开线程”,而是“数据到底该不该共享”。 很多并发问题,本质上都不是线程太多,而是: 这时候就会发现,有些数据其实根本没必要共享。 比如: 这种场景下, 一句话先说透: 这篇文章重点会讲清楚几件事: 它的核心语义很简单: 也就是说,看起来像一个变量,实际上一份变量会被拆成很多份,按线程隔离保存。 可以把它想成“每个线程一个独立抽屉”: 所以它不是同步工具,不负责协调线程之间的先后顺序。 它做的是另一件事: 先看一个最常见的问题。 这段代码有竞态条件,结果通常不对。 因为 多个线程同时做这件事,就会互相覆盖。 传统做法一般有两种: 优点是简单、正确。 缺点也很直接: 这个写法通常比 但本质上,所有线程还是在争同一个计数器。 如果业务允许“先局部累计,最后再合并”,那还有第三种思路: 这就是 很多高性能代码的思路,都是这一套。 同一个 这三个值不会互相覆盖。 通常会这样创建: 这里的工厂方法不是创建一次,而是: 也就是说,初始化逻辑是“每线程一次”,不是“全局一次”。 输出大致会是这样: 这里要注意两点: 最常见的是后两种。 读取或设置当前线程对应的值。 表示“当前线程”是否已经创建过值。 这个判断不是全局的,而是当前线程视角。 获取所有已跟踪线程的值。 但要注意,只有在构造时传了: 这个属性才有意义。 释放 只要生命周期不是跟进程同生共死,通常都应该在不用时及时释放。 这个例子最能说明 这种模式的优点是: 这类场景通常包括: 但这里也要把边界说清楚: 因为它本来就不是共享计数器。 老代码里经常会看到两种不太理想的写法: 这会有线程安全问题。 这种写法会反复创建对象,而且在某些旧代码场景里还可能出现种子过近的问题。 用 这种模式的价值是: 当然,如果只是普通场景下拿随机数,现代 但如果场景明确需要“线程私有状态”, 除了计数器, 比如每个线程都频繁拼接字符串,如果每次都 这时候可以给每个线程准备一个自己的 这种用法很适合: 不过也别走偏: 原因后面会讲。 这是 先看例子: 很多人第一次看到这里会困惑: 原因很简单: 线程一旦变了, 所以一定要记住: 如果需求是“跨 先把一句话讲明白: 举个直观点的判断方式: 如果是 ASP.NET Core 请求上下文、日志上下文之类的场景,基本都不该先想到 先看 它的特点是: 而 可以简单理解成: 如果只是极简单、极底层的线程静态字段,[ 如果需要初始化逻辑、生命周期管理、值跟踪,通常 这三个东西经常被拿来一起比较,但职责并不一样。 它不负责保护共享状态。 如果多线程真的要改同一个 而 所以两者往往不是谁替代谁,而是看业务语义。 如果问题是: 优先看共享同步方案。 如果问题是: 这才是 这个参数很有用,但不是白给的。 开启后, 代价是: 所以如果根本不需要 能不用就不用,只有在“确实需要全量汇总”时再开启。 这在短命线程里可能没什么感觉,但在线程池环境里,问题就会明显很多。 因为线程池线程不是用完就销毁,它们会长期存在。 如果 那这些对象可能会在线程上挂很久。 所以实战里要注意几条: 线程本地不等于免费缓存。 线程数一多,内存占用会按线程倍增。 特别是临时创建的 一个线程放 这还只是单个 本来以为“任务结束对象就没了”,实际可能只是任务结束了,线程还在,值也还在。 不能。 如果状态本身必须共享,它就帮不上忙。 在同步、固定线程模型里也许还凑合,但现代 不一定。 如果: 那 性能结论要看场景,不看想象。 这类代码最容易把小优化写成长期内存占用。 特别是缓存数组、缓冲区、连接对象这类资源,要非常克制。 比较适合: 不太适合: 下面这个例子演示一种很常见的模式: 这段代码里: 这就是比较典型的组合拳: 只要场景满足“每个线程维护自己的状态”,它通常会比锁更自然,也往往更高效。 但边界也很明确:简介
RandomStringBuilderThreadLocal<T> 就很合适。ThreadLocal<T> 的作用不是“让共享数据更安全”,而是“让数据干脆别共享”,直接给每个线程一份独立副本。ThreadLocal<T> 到底解决什么问题lock、Interlocked、AsyncLocal<T>、[ThreadStatic] 的边界是什么async/await 里经常踩坑ThreadLocal<T> 是什么?ThreadLocal<T> 位于:System.ThreadingThreadLocal<T> 实例.Value1100既然这份状态本来就不该共享,那就直接按线程拆开存。
为什么需要它?
int counter = 0;
Parallel.For(0, 100000, _ =>
{
counter++;
});counter++ 不是原子操作,它至少包含:1. 加锁
int counter = 0;
object sync = new();
Parallel.For(0, 100000, _ =>
{
lock (sync)
{
counter++;
}
});2. 用原子操作
int counter = 0;
Parallel.For(0, 100000, _ =>
{
Interlocked.Increment(ref counter);
});lock 更轻。3. 每个线程记自己的
using var localCounter = new ThreadLocal<int>(() => 0, trackAllValues: true);
Parallel.For(0, 100000, _ =>
{
localCounter.Value++;
});
int total = localCounter.Values.Sum();ThreadLocal<T> 的典型用法:它的工作方式是什么?
ThreadLocal<T> 有两个很关键的特点。1. 按线程隔离
ThreadLocal<int>,不同线程看到的是不同的 .Value。线程 A -> local.Value = 10
线程 B -> local.Value = 20
线程 C -> local.Value = 302. 延迟初始化
var local = new ThreadLocal<Random>(() => new Random());.Value最基础的 Demo:每个线程各用各的值
using System;
using System.Threading;
using var localNumber = new ThreadLocal<int>(() =>
{
Console.WriteLine($"线程 {Thread.CurrentThread.ManagedThreadId} 初始化");
return 0;
});
Thread t1 = new(() =>
{
localNumber.Value++;
localNumber.Value++;
Console.WriteLine($"线程 {Thread.CurrentThread.ManagedThreadId} 的值:{localNumber.Value}");
});
Thread t2 = new(() =>
{
localNumber.Value += 10;
Console.WriteLine($"线程 {Thread.CurrentThread.ManagedThreadId} 的值:{localNumber.Value}");
});
t1.Start();
t2.Start();
t1.Join();
t2.Join();线程 8 初始化
线程 9 初始化
线程 8 的值:2
线程 9 的值:10localNumber 变量,但值完全独立常用 API 先过一遍
构造函数
new ThreadLocal<T>()
new ThreadLocal<T>(Func<T> valueFactory)
new ThreadLocal<T>(Func<T> valueFactory, bool trackAllValues)Valuelocal.ValueIsValueCreatedlocal.IsValueCreatedValueslocal.ValuestrackAllValues: trueDispose()local.Dispose();ThreadLocal<T> 持有的资源和跟踪结构。Demo 2:高并发计数,先局部累加再汇总
ThreadLocal<T> 的实际价值。using System;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using var localCounter = new ThreadLocal<int>(() => 0, trackAllValues: true);
Parallel.For(0, 1_000_000, _ =>
{
localCounter.Value++;
});
int total = localCounter.Values.Sum();
Console.WriteLine($"总数:{total}");
Console.WriteLine($"线程份数:{localCounter.Values.Count}");
Console.WriteLine($"每个线程的局部计数:{string.Join(", ", localCounter.Values)}");如果业务要求“每次加一之后,全局值立刻可见”,那就不能用
ThreadLocal<T> 代替 Interlocked。Demo 3:给每个线程一个专属
RandomRandom 这个场景很经典。写法 1:多个线程共用一个
RandomRandom random = new();写法 2:每次都
new Random()int value = new Random().Next();ThreadLocal<Random> 会更稳:using System;
using System.Threading;
using System.Threading.Tasks;
using var localRandom = new ThreadLocal<Random>(() =>
new Random(HashCode.Combine(Environment.TickCount, Thread.CurrentThread.ManagedThreadId)));
Parallel.For(0, 8, i =>
{
int value = localRandom.Value.Next(1, 100);
Console.WriteLine($"线程 {Thread.CurrentThread.ManagedThreadId} -> {value}");
});Random.NET 里也常常可以直接用:Random.SharedThreadLocal<Random> 依然有意义。Demo 4:线程私有对象复用,减少临时分配
ThreadLocal<T> 另一个很常见的用途,就是对象复用。new StringBuilder(),分配会比较多。StringBuilder:using System;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using var localBuilder = new ThreadLocal<StringBuilder>(() => new StringBuilder(256));
Parallel.For(0, 10, i =>
{
StringBuilder sb = localBuilder.Value;
sb.Clear();
sb.Append("任务编号:").Append(i)
.Append(",线程:").Append(Thread.CurrentThread.ManagedThreadId);
Console.WriteLine(sb.ToString());
});ThreadLocal<T> 适合放“线程用完即可复用”的对象,不适合随手塞很大的长期对象。为什么它在
async/await 里容易出事?ThreadLocal<T> 最常见的误区之一。using System;
using System.Threading;
using System.Threading.Tasks;
using var localText = new ThreadLocal<string>(() => "未设置");
async Task DemoAsync()
{
localText.Value = "开始阶段";
Console.WriteLine($"await 前,线程 {Thread.CurrentThread.ManagedThreadId},值:{localText.Value}");
await Task.Delay(100);
Console.WriteLine($"await 后,线程 {Thread.CurrentThread.ManagedThreadId},值:{localText.Value}");
}
await DemoAsync();await 前明明赋值了await 后可能读不到刚才那个值?ThreadLocal<T> 绑定的是线程async/await 绑定的是异步执行流await 之后,续体不一定还回到原来的线程ThreadLocal<T> 对应的那份值也就变了。ThreadLocal<T> 适合线程上下文,不适合异步调用链上下文。await 继续带着这份值走”,应该看的是:AsyncLocal<T>ThreadLocal<T> 和 AsyncLocal<T> 到底怎么选?ThreadLocal<T>:按线程隔离AsyncLocal<T>:按异步调用链隔离适合
ThreadLocal<T> 的场景适合
AsyncLocal<T> 的场景await 传递的上下文信息ThreadLocal<T>。它和
[ThreadStatic] 有什么区别?[ThreadStatic] 也是线程本地存储,但两者不是一回事。[ThreadStatic] 的样子:[ThreadStatic]
private static int _value;static 字段ThreadLocal<T> 的优势在于:IsValueCreatedtrackAllValues: true 时拿到所有线程值Dispose()[ThreadStatic] 更接近“语法级线程字段”ThreadLocal<T> 更接近“功能完整的线程本地容器”ThreadStatic] 可以考虑。ThreadLocal<T> 更合适。它和
lock、Interlocked 的边界是什么?ThreadLocal<T> 不是锁Dictionary、同一个对象、同一个队列,还是得靠:lockMonitorReaderWriterLockSlimThreadLocal<T> 只能解决“别共享”这一类问题。它也不是原子操作替代品
Interlocked 解决的是:ThreadLocal<T> 解决的是:一个简单判断标准
ThreadLocal<T> 的地盘。trackAllValues: true 有什么代价?var local = new ThreadLocal<int>(() => 0, trackAllValues: true);ThreadLocal<T> 会额外跟踪所有线程创建过的值,方便最后通过 Values 做汇总。Values,就别顺手开着。生命周期问题一定要重视
ThreadLocal<T> 很容易被误用成“顺手一放就完事”。ThreadLocal<T> 里放的是:1. 不要把它当万能缓存
2. 不再使用时及时
Dispose()local.Dispose();ThreadLocal<T>,别让它一直悬着。3. 对象越大,越要谨慎
1MB 缓冲区不算什么,32 个线程就是 32MB,64 个线程就是 64MB。ThreadLocal<T>。4. 线程池线程复用会放大问题
常见误区
误区 1:
ThreadLocal<T> 能替代所有锁误区 2:
ThreadLocal<T> 适合请求上下文.NET 大量场景是 async/await,这时通常应该先看 AsyncLocal<T>。误区 3:
ThreadLocal<T> 一定更快ThreadLocal<T> 未必真的占优。误区 4:在线程池任务里放任何对象都没问题
什么时候适合用
ThreadLocal<T>?RandomStringBuilderawait 传递的数据一段更贴近实战的综合示例
StringBuilderusing System;
using System.Collections.Concurrent;
using System.Linq;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
var inputs = Enumerable.Range(1, 10000).ToArray();
var outputs = new ConcurrentBag<string>();
using var localBuilder = new ThreadLocal<StringBuilder>(() => new StringBuilder(128));
using var localSuccessCount = new ThreadLocal<int>(() => 0, trackAllValues: true);
Parallel.ForEach(inputs, item =>
{
StringBuilder sb = localBuilder.Value;
sb.Clear();
sb.Append("item=").Append(item)
.Append(", thread=").Append(Thread.CurrentThread.ManagedThreadId);
outputs.Add(sb.ToString());
localSuccessCount.Value++;
});
int totalSuccess = localSuccessCount.Values.Sum();
Console.WriteLine($"输出数量:{outputs.Count}");
Console.WriteLine($"成功数量:{totalSuccess}");
Console.WriteLine($"参与线程数:{localSuccessCount.Values.Count}");ConcurrentBag<string> 负责承接真正共享的输出结果ThreadLocal<StringBuilder> 负责减少线程内临时分配ThreadLocal<int> 负责线程内局部累计该共享的共享,该局部的局部,不要什么都硬塞进同一种并发手段里。
总结
ThreadLocal<T> 最核心的价值,不是“线程安全”这四个字本身,而是换一种思路:await