C# 线程同步原语详解

系统讲解 .NET 的线程同步原语,覆盖 lock 与 Monitor 的底层实现、SemaphoreSlim 与限流、Channel 生产者消费者、并发集合的语义,以及 Interlocked 与内存屏障的正确用法与常见死锁模式。

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 惰性初始化

并发编程的核心不是记住多少个原语,而是始终保持对“谁在何时持有何种状态”的清醒认识。绝大多数线上并发事故都可以追溯到一条被破坏的纪律:临界区过大、加锁顺序不统一、或在同步上下文中等待异步任务。守住这几条,就能规避绝大多数难以复现的故障。下一篇转向系统边界,讨论与原生代码互操作时的封送与资源管理。

延伸阅读

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「csharp」更多文章

  1. .NET 机器学习实战
  2. 内存剖析与 dump 分析
  3. 分布式事务与 Saga 编排