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;
}
}
三条铁律:
- 锁对象
private readonly object,绝不用lock(this)(外部也能锁你,死锁)、绝不锁字符串(字符串拘留导致跨不相关代码共享锁)。 - 锁内只做快操作:内存计算、集合操作。锁内绝不能
await(lock语法根本不允许 await,想异步用SemaphoreSlim.WaitAsync,见异步并发篇);锁内不做 IO、不调外部回调(回调里可能再拿别的锁 → 死锁)。 - 缩小临界区,但别为几行代码的可读性过度拆分------锁的开销在无竞争时约 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):下面这段代码想实现"每艘船只允许一个同步任务在跑",有什么并发问题?怎么改?
csharpif (!_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 线程) | 串行化,竞争越久越慢 | 细粒度锁,随核数扩展 | 每次写分配+树复制,最慢 |
| 写时读者看到的状态 | 一致(锁内) | 单方法原子,复合需自查 | 永远一致的旧快照或新快照 |
认知要点:
- 无竞争时 lock 极便宜(~20ns),别急着上无锁结构增加复杂度。
- 并发集合的扩展性优势只在高并发 + 多核下显现;单核/低并发下 lock 往往更快。
- Immutable 的写成本是真实分配压力(GC 成本见内存篇),读多写少才划算。
- 一切以 BenchmarkDotNet + 真实并发度实测为准,别背结论。
10. 十个踩坑
TryUpdate不检查返回值:CAS 失败静默丢更新(开篇计数器事故)。要么循环重试,要么换 Interlocked 容器(第 3.2 节)。GetOrAdd工厂有副作用 :工厂多次执行导致重复建连/重复发消息。副作用场景用Lazy<T>或锁+双检(第 3.3 节)。AddOrUpdate工厂依赖外部变量:工厂必须是纯函数(只用传入的 key/currentValue),闭包捕获外部状态在重试时产生错误结果。- 普通 Dictionary 并发写:未定义行为------.NET Framework 可能死循环(CPU 100%),.NET Core 可能数据损坏/异常。任何多线程共享必须换并发集合或加锁。
lock(this)/ 锁字符串 / 锁公开对象 :外部代码可与你争用同一把锁,死锁排查到怀疑人生。专用private readonly object。- 锁内 await / 锁内调外部回调 / 锁内做 IO:编译错误(await)或死锁/长临界区拖垮吞吐。异步等待用 SemaphoreSlim。
- 用
Count/IsEmpty做正确性判断:并发集合的 Count 是近似值且 O(n)。判断"有没有"用 Try 方法的返回值。 - 枚举中修改集合 :并发集合枚举不抛异常但给混合快照,基于快照做修改决策会漏/重。先
ToArray()拿快照再处理。 - 互动题答案------check-then-act 竞态 :
if (!_running.Contains(shipId)) { _running.Add(shipId); ... }两个线程可同时通过检查 → 同船两个同步任务并发(船岸数据错乱)。正确写法一行原子化:if (_running.TryAdd(shipId, token)) { _ = RunSync(shipId); },完成后_running.TryRemove(shipId, out _)(try/finally 保证移除)。 - 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 都藏在这三个问题的含糊回答里。