1. 并发原语的分类与选择
一句话总结: 同步原语按用途分为互斥、信号量、信号通知与无锁原子四类,选型错误比实现错误更常见,先明确要解决的是互斥、限流、唤醒还是竞态。
.NET 提供的同步设施看似庞杂,但按解决的问题可以分成四类:
| 问题 | 原语 | 阻塞方式 | 跨进程 |
|---|---|---|---|
| 临界区互斥 | lock / Monitor | 是 | 否 |
| 限流与许可 | SemaphoreSlim | 异步可等待 | 否 |
| 线程间通知 | ManualResetEventSlim | 是 | 否 |
| 生产者消费者 | Channel | 异步可等待 | 否 |
| 无锁计数 | Interlocked | 否 | 否 |
| 跨进程互斥 | Mutex | 是 | 是 |
选型的第一原则是优先选择不阻塞线程的异步原语。SemaphoreSlim.WaitAsync、Channel 的读写都返回 Task,能在等待时释放线程,而 Monitor.Enter、ManualResetEventSlim.Wait 会占用一个线程池线程直到条件满足。
// 阻塞式:等待期间占用线程池线程
private readonly object _gate = new();
public void 阻塞式写入(string v)
{
lock (_gate) { WriteSlow(v); }
}
// 异步式:等待期间释放线程
private readonly SemaphoreSlim _sem = new(1, 1);
public async Task 异步写入Async(string v)
{
await _sem.WaitAsync();
try { await WriteSlowAsync(v); }
finally { _sem.Release(); }
}
第二原则是区分互斥与顺序。互斥只保证同一时刻一个执行体进入临界区;若还需要保证 A 先于 B 执行,那是顺序问题,需要信号量或 Channel 而非 lock。
2. lock 与 Monitor 的底层
一句话总结: lock 编译为 Monitor.Enter/Exit 的 try-finally,锁对象头部的同步块索引承载状态,锁对象必须是引用类型且不可被外部获取。
lock (obj) { ... } 会被编译器改写为:
bool lockTaken = false;
try
{
Monitor.Enter(obj, ref lockTaken);
// 临界区
}
finally
{
if (lockTaken) Monitor.Exit(obj);
}
锁状态记录在对象头的同步块索引中。线程竞争时,Monitor 先自旋再升级为内核事件等待,因此短临界区的锁开销很低。
2.1 锁对象的纪律
一句话总结: 锁对象应为 private readonly object 或专用锁类型,绝不能用 this、Type、字符串字面量或值类型,否则外部可绕过或死锁。
// 反例 1:锁定 this,外部代码可锁定同一对象造成死锁
public void Bad1() { lock (this) { } }
// 反例 2:锁定 Type 对象,全进程共享,影响面不可控
public void Bad2() { lock (typeof(MyService)) { } }
// 反例 3:锁定字符串字面量,因字符串驻留而全进程共享
public void Bad3() { lock ("mylock") { } }
// 正例:专用私有锁对象
private readonly object _sync = new();
public void Good() { lock (_sync) { } }
.NET 9 引入了 System.Threading.Lock 类型,lock 语句对它使用专门的快速路径,比锁 object 更快且更清晰。
private readonly Lock _gate = new();
public void 更新状态(int delta)
{
lock (_gate) // 编译为 Lock.EnterScope,性能更好
{
_total += delta;
}
}
2.2 死锁的四种典型模式
一句话总结: 死锁源于加锁顺序不一致、锁内调用外部代码、同步等待异步任务与递归重入,规避手段是统一顺序、缩小临界区与禁止跨层回调。
// 模式一:加锁顺序不一致
void ThreadA() { lock (_a) { lock (_b) { } } }
void ThreadB() { lock (_b) { lock (_a) { } } } // 可能死锁
// 模式二:锁内同步等待异步任务(经典 sync-over-async 死锁)
public void Bad()
{
lock (_gate)
{
Task.Run(() => WorkAsync()).Wait(); // 内部可能又要拿 _gate
}
}
// 模式三:锁内触发事件,订阅者回调再次加锁
private void Notify()
{
lock (_gate) { Changed?.Invoke(this, EventArgs.Empty); } // 回调中再 lock(_gate) 即重入死锁
}
规避策略很明确:所有代码统一按固定顺序加锁;临界区内只做纯内存操作,不调用外部代码、不做 IO、不触发事件;异步方法一律用 async 全链路,不用 .Result 与 .Wait()。
3. SemaphoreSlim 与限流
一句话总结: SemaphoreSlim 是唯一支持异步等待的信号量,用于限制并发数、实现有界资源池与异步互斥,注意初始计数与释放次数的严格配对。
// 限制同时最多 4 个并发调用
private static readonly SemaphoreSlim _limit = new(initialCount: 4, maxCount: 4);
public static async Task<T> 限流执行Async<T>(Func<Task<T>> action)
{
await _limit.WaitAsync();
try { return await action(); }
finally { _limit.Release(); }
}
Release() 调用次数多于 WaitAsync() 会抛 SemaphoreFullException,这是排查“释放不配对”的直接线索。把 Release 放进 finally 是基本纪律。
3.1 超时与取消
一句话总结: 所有 WaitAsync 都应传入 CancellationToken 或超时,否则限流失效时调用方会无限等待。
public static async Task<bool> 尝试执行Async(Func<Task> action, TimeSpan timeout)
{
if (!await _limit.WaitAsync(timeout))
return false; // 超时未获取到许可
try { await action(); return true; }
finally { _limit.Release(); }
}
3.2 异步锁的封装
一句话总结: 用 SemaphoreSlim(1,1) 可以构造异步互斥锁,但要注意它不可重入,重入会永久死锁。
实现上把 SemaphoreSlim(1, 1) 包一层,LockAsync 等待后返回一个可释放句柄,Dispose 时用 Interlocked.Exchange 保证只 Release 一次,用法是 using (await _lock.LockAsync()) { ... }。注意 SemaphoreSlim 不是重入锁,同一执行流重复获取会死锁,这是与 lock 最重要的行为差异。
4. Channel 与生产者消费者
一句话总结: Channel<T> 是 .NET 首选的生产者消费者管道,有界通道提供背压,无界通道可能耗尽内存,读端必须处理完成信号。
// 有界通道:容量满时生产者等待,形成背压
var channel = Channel.CreateBounded<Job>(new BoundedChannelOptions(1000)
{
SingleReader = true,
SingleWriter = false,
FullMode = BoundedChannelFullMode.Wait,
});
// 生产者
public async Task 生产Async(Job job)
{
await channel.Writer.WriteAsync(job);
}
// 消费者
public async Task 消费Async(CancellationToken ct)
{
await foreach (var job in channel.Reader.ReadAllAsync(ct))
{
await ProcessAsync(job);
}
}
// 生产结束后必须标记完成,否则消费者永远等待
channel.Writer.Complete();
BoundedChannelOptions 的三个开关直接影响性能:SingleReader 与 SingleWriter 为 true 时通道可以走无锁快速路径;AllowSynchronousContinuations 为 true 时生产者线程会直接执行消费者的续体,吞吐更高但可能造成线程饥饿。
4.1 背压与丢弃策略
一句话总结: 有界通道的 FullMode 决定溢出行为,Wait 保证不丢数据但会阻塞生产者,DropOldest 与 DropWrite 保证吞吐但会丢数据,须按业务语义选择。
| FullMode | 行为 | 适用 |
|---|---|---|
| Wait | 生产者异步等待 | 不可丢数据,如订单 |
| DropNewest | 丢弃最新写入 | 实时性优先 |
| DropOldest | 丢弃最旧 | 监控指标 |
| DropWrite | 丢弃当前写入 | 采样 |
// 指标采集:丢旧数据,保证处理的是最新值
var metrics = Channel.CreateBounded<Metric>(new BoundedChannelOptions(10_000)
{
FullMode = BoundedChannelFullMode.DropOldest,
SingleReader = true,
});
4.2 Channel 与 BlockingCollection 的取舍
一句话总结: 新代码一律用 Channel,它是异步优先、性能更好;BlockingCollection 只在纯同步且需与旧代码兼容时保留。
// 旧式同步写法,消费者阻塞线程
var blocking = new BlockingCollection<Job>(boundedCapacity: 1000);
blocking.Add(job); // 满时阻塞
foreach (var j in blocking.GetConsumingEnumerable()) { }
BlockingCollection 在等待时占用线程,且不支持异步,在 ASP.NET Core 这类线程池敏感的宿主中会放大线程饥饿风险。
5. 并发集合
一句话总结: 并发集合通过细粒度锁或无锁算法提供线程安全,但组合操作仍非原子,GetOrAdd 的工厂可能被多次调用,枚举是快照语义。
.NET 提供的并发集合包括 ConcurrentDictionary、ConcurrentQueue、ConcurrentStack、ConcurrentBag、BlockingCollection。它们保证单个操作线程安全,但不保证跨操作的组合原子性。
// 反例:检查与更新分离,两个线程可能都通过检查
if (!_cache.ContainsKey(key))
_cache[key] = Load(key); // 竞态:可能重复加载
// 正例:原子操作
var entry = _cache.GetOrAdd(key, k => Load(k));
GetOrAdd 的工厂委托在竞争下可能被并发执行多次,返回给调用方的值只有一个生效,其余被丢弃。若工厂有副作用(如打开文件句柄、扣减额度),必须改为:
public static TValue 只执行一次<TKey, TValue>(
ConcurrentDictionary<TKey, TValue> dict, TKey key, Func<TKey, TValue> factory)
where TKey : notnull
{
if (dict.TryGetValue(key, out var existing)) return existing;
var created = factory(key);
return dict.GetOrAdd(key, created); // 若已被他人写入,返回他人值
}
5.1 枚举与快照语义
一句话总结: 并发集合的枚举返回的是弱一致性快照,遍历期间的修改可能可见也可能不可见,需要强一致快照时应自行复制。
// 弱一致:遍历期间的写入可能被看到
foreach (var kv in _cache) { }
// 强一致快照:先复制再遍历
foreach (var kv in _cache.ToArray()) { }
ConcurrentDictionary.ToArray() 内部会加锁获取一致视图,代价是一次分配,但语义明确。
6. Interlocked 与内存屏障
一句话总结: Interlocked 提供无锁原子读写与比较交换,天然带完整内存屏障;volatile 只保证可见性与顺序,不保证原子性,两者解决不同问题。
private long _counter;
// 原子自增,返回自增后的值
public long 下一号() => Interlocked.Increment(ref _counter);
// 比较并交换:无锁实现乐观更新
public bool 尝试更新(ref int target, int expected, int value)
=> Interlocked.CompareExchange(ref target, value, expected) == expected;
// 无锁累加(高竞争下优于 CAS 循环)
public void 累加(long delta) => Interlocked.Add(ref _counter, delta);
Interlocked.Exchange 适合实现一次性初始化与幂等释放:用一个 int 标志位,Exchange 返回 0 表示自己抢到了执行权,其余线程看到非 0 直接跳过,天然实现“只执行一次”。
6.1 volatile 与发布安全
一句话总结: volatile 保证字段读写的可见性与禁止重排序,但不保证复合操作原子性;对象发布必须避免部分构造可见。
// 双重检查锁定:需要 volatile 防止读到未完全构造的对象
private volatile Singleton? _instance;
private readonly object _lock = new();
public Singleton Instance
{
get
{
if (_instance is null)
{
lock (_lock)
{
_instance ??= new Singleton();
}
}
return _instance;
}
}
在现代 .NET 上,更推荐用 Lazy<T>(配合 LazyThreadSafetyMode.ExecutionAndPublication)或静态构造器,它们由运行时保证线程安全的惰性初始化,既无需手写双重检查,也不依赖 volatile 的正确性推理。
6.2 何时真的需要屏障
一句话总结: 绝大多数业务代码不需要手写屏障,Interlocked 与 lock 已隐含足够语义;只有实现无锁数据结构时才需显式理解 acquire 与 release 语义。
Thread.MemoryBarrier()、Volatile.Read/Write 属于实现无锁算法时的工具。普通业务中,用 lock 保护共享状态、用 Interlocked 做计数器,就已经获得正确的内存语义,手写屏障反而容易引入难以复现的 bug。
7. 常见陷阱与排查
一句话总结: 并发缺陷的特点是难以复现,排查应依赖确定性测试、压力测试与线程转储,而非靠加日志猜测。
第一,避免 async void。async void 的异常无法被捕获,会直接崩溃进程,且调用方无法等待其完成。事件处理器是唯一例外。
// 危险
public async void 处理Async() { await WorkAsync(); }
// 安全
public async Task 处理Async() { await WorkAsync(); }
第二,不要在锁内 await。lock 的代码块不允许 await,若强行用 SemaphoreSlim 代替,也要确认临界区内没有异步等待,否则会长时间持有许可。
第三,用压力测试暴露竞态。并发缺陷在单线程测试下不可见,需要多线程反复执行并校验不变量。
[Fact]
public async Task 并发自增_结果正确()
{
var counter = new AtomicCounter();
var tasks = Enumerable.Range(0, 100)
.Select(_ => Task.Run(() =>
{
for (int i = 0; i < 10_000; i++) counter.Increment();
}))
.ToArray();
await Task.WhenAll(tasks);
Assert.Equal(1_000_000, counter.Value); // 非原子实现会小于该值
}
第四,用 dotnet-dump 与 dotnet-stack 分析挂起。当进程疑似死锁时,抓取线程转储并查看各线程的调用栈与锁持有关系,比猜更快。
dotnet-dump collect --process-id <pid> --type Full
dotnet-dump analyze core_<pid> --command "clrstack -all"
第五,区分线程饥饿与死锁。若所有线程都在等待同一个资源且无人释放,是死锁;若线程池线程被阻塞式等待耗尽,是饥饿,通常源于 sync-over-async。
8. 总结
| 环节 | 要点 |
|---|---|
| 选型 | 先分清互斥、限流、通知、无锁四类问题,优先异步原语 |
| lock | 编译为 Monitor,锁对象须私有且专用,禁止锁 this、Type、字符串 |
| 死锁 | 统一加锁顺序,临界区内不调外部代码、不阻塞等待异步 |
| SemaphoreSlim | 唯一异步信号量,Release 与 WaitAsync 严格配对,注意不可重入 |
| Channel | 首选生产者消费者管道,有界通道提供背压,读完须 Complete |
| 并发集合 | 单操作线程安全,组合操作仍需自行加锁,工厂可能重复执行 |
| Interlocked | 无锁原子操作自带屏障,volatile 只管可见性,优先 Lazy 惰性初始化 |
并发编程的核心不是记住多少个原语,而是始终保持对“谁在何时持有何种状态”的清醒认识。绝大多数线上并发事故都可以追溯到一条被破坏的纪律:临界区过大、加锁顺序不统一、或在同步上下文中等待异步任务。守住这几条,就能规避绝大多数难以复现的故障。下一篇转向系统边界,讨论与原生代码互操作时的封送与资源管理。
延伸阅读
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。