.NET 线程安全集合与并发数据结构深度实战:从 lock 到无锁

0. 开篇:一个计数器丢了 37 次更新

船岸同步服务统计每艘船的消息处理量,用来做监控大盘。代码很"现代":

csharp 复制代码
private static readonly ConcurrentDictionary<string, int> _shipCounters = new();

// 消息处理成功后
_shipCounters.AddOrUpdate(shipId, addValue: 1, updateValueFactory: (_, count) => count + 1);

上线几天后发现:HKMW-007 轮明明处理了 200 多条消息,计数器只显示 163。ConcurrentDictionary 不是线程安全的吗?怎么还会丢更新?

把时间倒推到更早的另一个事故:某服务用普通 Dictionary<string, Session> 存 SignalR 在线连接,上线后偶发 CPU 100%------抓 dump 发现一个线程在字典里无限循环(经典的 .NET Framework 字典并发写死循环,.NET Core 上结构变了但并发写依然是未定义行为,表现为数据损坏或异常)。

这两个事故的共同点:"线程安全集合"四个字只保证集合内部结构不损坏,不保证你用它写的复合逻辑是对的。 本篇把 .NET 并发数据结构讲到底层:锁是怎么加的、无锁是怎么做到的、每个集合的正确姿势和陷阱------全部结合 PMS 真实场景。

💬 互动一下(1/3) :先回答开篇第一个事故------AddOrUpdate 为什么会丢更新?提示:想想 updateValueFactory: (_, count) => count + 1 这个 lambda 在并发下可能被执行多少次。答案在第 3 节。


1. 地基:并发问题的三兄弟和 lock 的真相

1.1 原子性、可见性、有序性

特性 含义 违反它的典型后果
原子性 操作中途不被打断 count++ 是"读-加-写"三步,并发下丢更新
可见性 一个线程的写,其他线程立刻能看到 线程把变量读进 CPU 寄存器/缓存,别人改了它看不到(死循环读旧值)
有序性 代码按书写顺序生效 编译器/CPU 重排指令,双检锁单例经典 bug

lock(Monitor)一次性解决三者:进入锁获得内存屏障(barrier),保证块内操作原子、块内读写对随后进入锁的线程可见、不跨屏障重排。

1.2 lock 的正确姿势

csharp 复制代码
private readonly object _gate = new();   // ① private readonly,专用锁对象
private readonly Dictionary<string, Session> _sessions = new();

public void Add(string id, Session s)
{
    lock (_gate)          // ② 锁对象绝不对外暴露、绝不用 this/string
    {
        _sessions[id] = s;
    }
}

三条铁律:

  1. 锁对象 private readonly object绝不用 lock(this) (外部也能锁你,死锁)、绝不锁字符串(字符串拘留导致跨不相关代码共享锁)。
  2. 锁内只做快操作:内存计算、集合操作。锁内绝不能 awaitlock 语法根本不允许 await,想异步用 SemaphoreSlim.WaitAsync,见异步并发篇);锁内不做 IO、不调外部回调(回调里可能再拿别的锁 → 死锁)。
  3. 缩小临界区,但别为几行代码的可读性过度拆分------锁的开销在无竞争时约 20ns,有竞争才贵。

1.3 什么时候 lock+普通集合就够了,别过度工程

  • 集合访问频率低、临界区短(如启动配置、低频管理操作):lock + Dictionary/List 最简单、最不容易错。
  • 并发集合的价值在"读多写少/高并发细粒度访问":它的 API 更复杂、行为坑更多,不是银弹。选型见第 9 节。

2. 内存模型补漏:volatile 与 Interlocked

  • volatile :禁止编译器把字段读缓存到寄存器,每次读都走内存,并插入半屏障禁止特定重排。典型用途:private static volatile bool _stopping; 后台循环退出标志。volatile 不保证复合操作原子volatile int x; x++ 依然丢更新)。
  • Interlocked :CPU 级原子指令(x86 上是 lock xadd / lock cmpxchg),是所有无锁结构的原语:
csharp 复制代码
Interlocked.Increment(ref _counter);              // 原子 ++
Interlocked.Add(ref _counter, 5);                 // 原子加
Interlocked.Exchange(ref _field, newValue);       // 原子写
Interlocked.CompareExchange(ref _field, newVal, comparand);  // CAS:等于 comparand 才替换
Interlocked.MemoryBarrier();                      // 全内存屏障

CAS(Compare-And-Swap)是无锁编程的灵魂:"我认为当前值是 X,如果是,就换成 Y;如果不是(被人改过),告诉我,我重试"------乐观策略,不挂起线程。


3. ConcurrentDictionary 深度篇(事故主角)

3.1 它的内部结构:细粒度锁 + 无锁读

ConcurrentDictionary<TKey,TValue> 底层是分桶数组 + 每个桶一把锁

  • 读路径完全无锁:读直接走 volatile 读桶链表/节点,不拿锁------这就是它读快的原因。
  • 写路径锁桶:写只锁目标 key 所在的桶,不同桶的写互不阻塞(默认并发度 = CPU 核数,.NET Core 上桶数随容量增长)。
  • 节点用不可变快照思路:更新 value 时创建新节点替换,读者要么看到旧节点要么看到新节点,不会看到半截。

3.2 开篇事故解密:AddOrUpdate 的工厂会执行多次

csharp 复制代码
_shipCounters.AddOrUpdate(shipId, 1, (_, count) => count + 1);

updateValueFactory 的执行不在锁内(为了不阻塞读)。两个线程同时更新同一个 key:

复制代码
线程A:读到 count=100,算 100+1=101,准备 CAS 写回
线程B:读到 count=100,算 100+1=101,准备 CAS 写回
一个 CAS 成功,另一个失败后重试 → 工厂又执行一次,但它闭包捕获的 count 还是旧的 100
结果:两次逻辑加一,最终值 101,丢一次

等等------重试时框架会用最新值再调工厂吗?会的,重试拿的是新当前值。但真正的坑在工厂里写了有副作用或依赖外部状态的逻辑 。对 count+1 这种纯函数,重试机制最终能收敛到正确值......除非你写成了下面这样(事故真实代码):

csharp 复制代码
// 错误:工厂里用的不是框架传入的 count,而是外部捕获的变量
var current = _shipCounters.GetOrAdd(shipId, 0);
_shipCounters.TryUpdate(shipId, current + 1, current);  // CAS 失败返回 false,没人重试!

TryUpdate 失败返回 false------如果不检查返回值不重试,更新就静默丢失。这正是 200 条消息只数到 163 的真相:高并发下 37 次 CAS 失败被吞了。

正确姿势:计数用 Interlocked 风格的重试循环,或直接用值容器

csharp 复制代码
// 方案一:值里放强原子计数(推荐,语义最清晰)
private static readonly ConcurrentDictionary<string, StrongBox<int>> _counters = new();

public void Increment(string shipId)
{
    var box = _counters.GetOrAdd(shipId, _ => new StrongBox<int>(0));
    Interlocked.Increment(ref box.Value);   // 原子,无丢失
}
// .NET 8+ 没有内置 AtomicInteger,StrongBox<int> + Interlocked 就是标准写法

// 方案二:CAS 重试循环(通用模式)
public void IncrementCas(string shipId)
{
    var dict = _shipCounters;
    while (true)
    {
        if (dict.TryGetValue(shipId, out var current))
        {
            if (dict.TryUpdate(shipId, current + 1, current)) return;  // 失败就转圈重试
        }
        else if (dict.TryAdd(shipId, 1)) return;
    }
}

3.3 GetOrAdd 工厂陷阱(异步篇也提过,这里讲透)

csharp 复制代码
// 陷阱:工厂可能被执行多次!
var session = _sessions.GetOrAdd(shipId, id => CreateExpensiveSession(id));

两个线程同时 miss,两个线程都会执行 CreateExpensiveSession(建连接、发握手包),只有一个结果进字典,另一个被丢弃------但副作用已经发生了:船端收到两次握手、建了两条连接,一条泄漏。

三种解法:

csharp 复制代码
// ① 工厂必须无副作用、幂等、便宜(适合值类型/常量)
var cfg = _dict.GetOrAdd(shipId, id => LoadConfig(id));   // LoadConfig 幂等则无害,重复结果一样

// ② 用 Lazy<T>(经典解法,工厂只在 Lazy.Value 被访问时执行一次)
var lazy = _lazyDict.GetOrAdd(shipId,
    id => new Lazy<Session>(() => CreateExpensiveSession(id),
                            LazyThreadSafetyMode.ExecutionAndPublication));
var session = lazy.Value;   // 并发下工厂保证只执行一次

// ③ 异步场景:SemaphoreSlim 双检 + 异步工厂(见异步并发篇),
//    或 .NET 8+ 的 ConcurrentDictionary 配合 AsyncLazy 模式

3.4 枚举与快照

  • GetEnumerator() 返回快照语义的近似值 :枚举过程中字典被改了,不抛异常,但你看到的是某一瞬间的混合视图------适合监控统计,不适合用来做"遍历并修改"的决策。
  • 遍历中安全修改要用快照:foreach (var kv in _dict.ToArray()) { ... }(ToArray 是一次性快照)。
  • Count / IsEmpty 都是近似值,别用在正确性判断上。

3.5 复合操作原则

ConcurrentDictionary 保证单个方法调用原子,不保证"检查再操作":

csharp 复制代码
// ❌ 错误:ContainsKey + Add 之间有窗口
if (!_dict.ContainsKey(id)) _dict[id] = BuildValue(id);
// ✅ 正确:TryAdd 一步完成
_dict.TryAdd(id, BuildValue(id));

凡是"先查后改",都必须合并成单个原子方法(TryAdd/TryUpdate/AddOrUpdate/GetOrAdd),或外加锁。


4. 并发队列三件套:Queue / Stack / Bag

4.1 ConcurrentQueue:无锁 FIFO

底层是分段链表 + Interlocked 操作(MPSC/MPMC 友好):入队在尾段 CAS 挂新段,出队在头段 CAS 推进头指针。

csharp 复制代码
var queue = new ConcurrentQueue<OutboxMessage>();
queue.Enqueue(msg);
if (queue.TryDequeue(out var m)) { /* 处理 */ }
queue.TryPeek(out _);

关键认知:

  • TryDequeue 不阻塞 :队列空就返回 false。要阻塞等待用 BlockingCollection(第 5 节)或 Channel<T>(异步版,异步并发篇)。
  • 它的枚举也是快照;Count 是 O(n) 近似统计(要遍历分段),别在热路径调 Count
  • 多消费者下严格 FIFO 只保证"单生产者"视角;多生产者入队顺序取决于线程调度。

4.2 ConcurrentStack:无锁 LIFO

基于 Interlocked.CompareExchange 交换头指针(Treiber stack 思想)。TryPop / Push / TryPopRange。适用场景极少但很精准:对象池的空闲链表------借还栈式复用,最近归还的对象还在 CPU 缓存里(缓存亲和性)。

4.3 ConcurrentBag:无序、线程本地亲和

最特殊的一个:每个线程有自己的本地队列,往自己的本地队列 Add/TryTake 完全无锁无竞争,本地空了才去偷别的线程的(work-stealing)。

  • 适合:并行计算产生的临时结果收集Parallel.ForEach 里往 Bag 塞结果),线程数固定且 Add/Take 都在同线程。
  • 不适合:需要顺序、需要跨线程公平消费的场景;它的 Count 更贵。
  • PMS 例子:批量导入 5000 条备件做校验,Parallel.ForEachAsync 里每个任务把校验失败项扔进 ConcurrentBag,最后汇总错误报告。

5. BlockingCollection:有界阻塞队列(经典生产者-消费者)

BlockingCollection<T>带阻塞语义和容量上限的并发集合包装器(默认包 ConcurrentQueue):

csharp 复制代码
private readonly BlockingCollection<ReportJob> _jobs =
    new(boundedCapacity: 100);   // 最多 100 个待办,满了生产者阻塞 → 天然背压

// 生产者(Hangfire 拉取任务线程)
_jobs.Add(job, stoppingToken);     // 队列满时阻塞等待,直到消费者腾出位置

// 消费者(工作线程)
foreach (var job in _jobs.GetConsumingEnumerable(stoppingToken))  // 空时阻塞等待
{
    Process(job);
}
// 生产者全部完成后调用 CompleteAdding(),消费者枚举自然结束
_jobs.CompleteAdding();

要点:

  • 有界容量 = 同步背压 :防止生产快消费慢时内存爆掉------和 Channels 的 BoundedChannelFullMode.Wait 是同一个思想,只是它面向同步阻塞线程模型。
  • 支持 TryAdd(timeout) / TryTake(timeout)、多消费者(多个线程同时 GetConsumingEnumerable 自动分摊)。
  • 现代 .NET 异步代码优先用 Channel<T>(await 不阻塞线程,见异步并发篇);BlockingCollection 适合 Thread/Task 长驻工作线程模型,或对接老代码。

6. Immutable Collections:快照即正义

System.Collections.Immutable 提供另一套哲学:集合一旦创建永不可变,任何"修改"都返回一个新集合,底层用持久化数据结构(树/块共享大部分内存)。

csharp 复制代码
ImmutableDictionary<string, ShipConfig> configs = ImmutableDictionary<string, ShipConfig>.Empty;

configs = configs.SetItem("HKMW-001", cfg);   // 返回新实例!原对象不变
configs = configs.Remove("HKMW-002");
var cfg = configs.GetValueOrDefault("HKMW-001");

var list = ImmutableList<int>.Empty.Add(1).Add(2).Add(3);

核心特性:

  • 天然线程安全,无需任何锁 :读者永远持有一个不可变快照,写者发布新版本用 Interlocked.Exchange/volatile 即可。
  • 读多写极少的共享状态神器:路由表、开关配置、权限矩阵------读路径零锁零分配(读不分配),写时复制共享结构。
  • 每次写分配新节点(写有成本),绝不能用于高频写(如计数器、热队列)。

PMS 实战------YARP 动态路由表(网关篇讲过 IProxyConfigProvider 动态路由):

csharp 复制代码
public sealed class ShipRouteTable
{
    private ImmutableDictionary<string, ShipRouteEntry> _routes =
        ImmutableDictionary<string, ShipRouteEntry>.Empty;

    // 读路径:配置刷新是低频的,每个请求都读------零锁
    public ShipRouteEntry? Lookup(string shipId) =>
        _routes.TryGetValue(shipId, out var e) ? e : null;

    // 写路径:配置变更时整体替换(原子发布)
    public void ReplaceAll(IReadOnlyDictionary<string, ShipRouteEntry> latest)
    {
        var builder = ImmutableDictionary.CreateBuilder<string, ShipRouteEntry>();
        foreach (var kv in latest) builder.Add(kv.Key, kv.Value);
        Volatile.Write(ref _routes, builder.ToImmutable());   // 原子换引用
    }
}

读者永远看到要么全旧要么全新的路由表,不存在"更新一半"的中间态------这是 lock 方案里要小心维护的不变量,immutable 免费获得。


7. ReaderWriterLockSlim:读多写少的经典锁

如果坚持用可变普通集合但读远多于写,ReaderWriterLockSlim 允许多读者并发、写者独占:

csharp 复制代码
private readonly ReaderWriterLockSlim _rw = new();
private readonly Dictionary<string, ShipProfile> _profiles = new();

public ShipProfile? Get(string id)
{
    _rw.EnterReadLock();
    try { return _profiles.GetValueOrDefault(id); }
    finally { _rw.ExitReadLock(); }
}

public void Set(string id, ShipProfile p)
{
    _rw.EnterWriteLock();
    try { _profiles[id] = p; }
    finally { _rw.ExitWriteLock(); }
}

实话实说:这类场景现在首选 ImmutableDictionary(第 6 节)或 ConcurrentDictionary,ReaderWriterLockSlim 的递归锁陷阱、升级锁(EnterUpgradeableReadLock)复杂度让它在现代代码里出场率下降。它仍适用的场景:写不算极少(Immutable 写成本高)、但读占绝大多数、且临界区逻辑复杂无法用并发集合表达时。


8. 并发集合 vs Channels vs lock:选型决策表

场景 选择 理由
高频计数、指标累加 ConcurrentDictionary<K, StrongBox<int>> + Interlocked AddOrUpdate 工厂有坑,Interlocked 原子
缓存、会话表、本地注册表 ConcurrentDictionary(注意 GetOrAdd 副作用) 无锁读、细粒度写锁
进程内工作队列(同步线程消费) BlockingCollection(有界) 阻塞 + 背压开箱即用
进程内工作队列(async/await) Channel<T>(有界) await 不占线程,背压传导(异步篇)
Parallel 结果收集 ConcurrentBag 线程本地无竞争
对象池空闲链表 ConcurrentStack 缓存亲和
路由表/配置表/权限矩阵(读极多、写极少) ImmutableDictionary + Volatile 发布 读零锁、快照一致
低频管理操作、临界区短 lock + 普通集合 最简单最不易错
需要"检查+多步修改"原子 外加 lock,或 CAS 重试循环 并发集合只保证单方法原子

PMS 船岸同步服务的最终组合(重构后):

  • 每船消息计数:ConcurrentDictionary<string, StrongBox<long>> + Interlocked;
  • 待发消息队列:Channel<OutboxMessage>(有界 1000,Wait 模式背压,异步消费);
  • 船连接会话表:ConcurrentDictionary<string, Lazy<ShipSession>>(Lazy 防重复建连);
  • 同步开关/路由配置:ImmutableDictionary,配置变更整体替换;
  • 报表导出任务池:BlockingCollection(老的 Thread 工作线程模型,后续迁 Channels)。

💬 互动一下(2/3):下面这段代码想实现"每艘船只允许一个同步任务在跑",有什么并发问题?怎么改?

csharp 复制代码
if (!_running.Contains(shipId)) { _running.Add(shipId); RunSync(shipId); }

提示:这是经典的"check-then-act"竞态;改成一行原子操作即可。答案见第 10 节踩坑清单。


9. 性能认知:别凭感觉选

BenchmarkDotNet(.NET 8,16 线程混合读写,参考量级)的几个结论:

操作 lock+Dictionary ConcurrentDictionary ImmutableDictionary
纯读(16 线程) ~25ns(无竞争)/ 高竞争下串行化 ~12ns(无锁读) ~15ns(无锁)
纯写(16 线程) 串行化,竞争越久越慢 细粒度锁,随核数扩展 每次写分配+树复制,最慢
写时读者看到的状态 一致(锁内) 单方法原子,复合需自查 永远一致的旧快照或新快照

认知要点:

  1. 无竞争时 lock 极便宜(~20ns),别急着上无锁结构增加复杂度。
  2. 并发集合的扩展性优势只在高并发 + 多核下显现;单核/低并发下 lock 往往更快。
  3. Immutable 的写成本是真实分配压力(GC 成本见内存篇),读多写少才划算。
  4. 一切以 BenchmarkDotNet + 真实并发度实测为准,别背结论。

10. 十个踩坑

  1. TryUpdate 不检查返回值:CAS 失败静默丢更新(开篇计数器事故)。要么循环重试,要么换 Interlocked 容器(第 3.2 节)。
  2. GetOrAdd 工厂有副作用 :工厂多次执行导致重复建连/重复发消息。副作用场景用 Lazy<T> 或锁+双检(第 3.3 节)。
  3. AddOrUpdate 工厂依赖外部变量:工厂必须是纯函数(只用传入的 key/currentValue),闭包捕获外部状态在重试时产生错误结果。
  4. 普通 Dictionary 并发写:未定义行为------.NET Framework 可能死循环(CPU 100%),.NET Core 可能数据损坏/异常。任何多线程共享必须换并发集合或加锁。
  5. lock(this) / 锁字符串 / 锁公开对象 :外部代码可与你争用同一把锁,死锁排查到怀疑人生。专用 private readonly object
  6. 锁内 await / 锁内调外部回调 / 锁内做 IO:编译错误(await)或死锁/长临界区拖垮吞吐。异步等待用 SemaphoreSlim。
  7. Count/IsEmpty 做正确性判断:并发集合的 Count 是近似值且 O(n)。判断"有没有"用 Try 方法的返回值。
  8. 枚举中修改集合 :并发集合枚举不抛异常但给混合快照,基于快照做修改决策会漏/重。先 ToArray() 拿快照再处理。
  9. 互动题答案------check-then-act 竞态if (!_running.Contains(shipId)) { _running.Add(shipId); ... } 两个线程可同时通过检查 → 同船两个同步任务并发(船岸数据错乱)。正确写法一行原子化:if (_running.TryAdd(shipId, token)) { _ = RunSync(shipId); },完成后 _running.TryRemove(shipId, out _)(try/finally 保证移除)。
  10. Immutable 集合忘了接返回值configs.SetItem(...) 不改原对象,返回新实例;写成"调了不接"等于没改。而且发布新引用要用 Volatile.Write/Interlocked,保证其他线程立即可见。

11. 落地 Checklist

  • 共享可变状态先问:能不能不共享(ThreadLocal/局部变量)?不能再选集合
  • 锁对象一律 private readonly object _gate;锁内不 await、不 IO、不回调
  • 计数累加用 StrongBox<int/long> + Interlocked,不用 AddOrUpdate 闭包
  • GetOrAdd 工厂保持无副作用;昂贵/有副作用的创建走 Lazy<T>
  • 所有"先查后改"合并为 TryAdd/TryUpdate 单方法或外加锁;TryUpdate 检查返回值
  • 异步队列默认 Channel.CreateBounded,同步工作线程用 BlockingCollection
  • 配置/路由/权限等读极多写极少的共享表用 Immutable* + Volatile 发布
  • Parallel 结果收集用 ConcurrentBag,顺序消费队列用 ConcurrentQueue/Channel
  • 不依赖并发集合的 Count/枚举做正确性判断
  • 关键并发逻辑有压测(多线程 Benchmark / Testcontainers 集成测试),不靠"想"

💬 互动一下(3/3):复盘你们服务里的共享状态------有多少其实是"伪共享"(请求级局部数据被错误地放进了静态字段)?真正需要跨请求共享的,有没有踩过 check-then-act 或工厂副作用?我的经验:代码评审时只要看到静态字典/静态列表字段,就问三个问题------"谁写?谁读?复合操作怎么原子?",九成并发 bug 都藏在这三个问题的含糊回答里。


相关推荐
机器学习之心2 小时前
基于BiGRU-Attention的轴承剩余寿命预测(MATLAB实现):从振动信号到RUL曲线的完整闭环
数据结构·算法·matlab·轴承剩余寿命预测·振动信号·bigru-attention
青梅橘子皮2 小时前
优选算法---专题2(滑动窗口)
数据结构·算法
程序猫.2 小时前
双指针问题
java·数据结构·算法
梦想很大很大2 小时前
从 Scope State 到 Guardrails:Workrun 如何为 Agent 工作流建立安全边界
安全·agent·workflow
50万马克的面包2 小时前
数据结构:线性表 —— 顺序表与链表完整总结
数据结构·链表
白狐_7982 小时前
408 数据结构|散列表构造题:开放定址法做题套路
数据结构·散列表
Neighbor_OldY2 小时前
云上证书过期与HTTPS安全治理实战:证书到期没人管、自动化续期与证书链排查
安全·https·自动化
咖啡星人k3 小时前
2026 智能体安全进阶:把注入和越权写进SPEC,MonkeyCode 云端跑通
人工智能·安全·机器学习
迅利科技3 小时前
航空 EWIS 线束数字化,CATIA 如何保障复杂机载电气流体系统安全合规
安全·系统安全