微信搜索superit|邀请体验:大数据, 数据管理、OLAP分析与可视化平台 | 赞助作者:赞助作者

C#的各种锁机制

 

C# 锁机制全景与高效实践:从 Monitor 到 .NET 9 全新 Lock

引言:线程安全与锁的基本概念

线程安全

在多线程编程中,保障共享资源的安全访问依赖于有效的线程同步机制。理解并处理好以下两个核心概念至关重要:

  • 线程安全:指某个类、方法或数据结构能够在被多个线程同时访问或修改时,依然保持内部状态的一致性,并产生预期的结果。这通常意味着需要对共享状态(如全局变量、静态变量或对象实例字段)的并发访问进行有效管控,防止数据损坏或不一致性。
  • 竞态条件 (Race Condition): 是一种典型的并发缺陷。当多个线程在缺乏适当同步机制的情况下,无序地、竞争性地访问或修改共享资源时,程序执行结果变得依赖于无法预测的线程调度时序(即执行顺序)。这种不确定性常常会导致数据错误、程序崩溃或行为异常。竞态条件是线程安全缺失的直接体现。

锁的基本概念

  • 锁的本质:锁是一种同步工具,用于确保共享资源的互斥访问(一次只有一个线程使用)。当一个线程获得锁并执行被保护的代码段(临界区)时,其他试图获取同一锁的线程会被阻塞或等待,直到锁被释放。
  • 锁的目标:在保证正确性的前提下,最大化并发度和系统吞吐量,最小化延迟。
  • 锁的代价:
    • 阻塞开销:操作系统调度上下文切换的成本。
    • 自旋开销:忙等待消耗CPU周期。
    • 死锁风险:线程因相互等待对方释放锁而永久僵持。
    • 优先级反转:低优先级线程持有高优先级线程需要的锁。
    • 复杂性:使用不当可能导致程序难以理解和调试。
    • 选择锁的依据:临界区大小、等待时间长短、竞争激烈程度、读/写比例、进程边界、公平性要求等。

1. Monitor

原理

Monitor类提供了一种互斥锁机制,确保同一时间只有一个线程可以访问临界区。它是C#中lock语句的基础,通过Monitor.EnterMonitor.Exit实现锁的获取和释放。

基于对象的内部 SyncBlock 索引关联的一个系统锁对象。每个.NET对象在堆上分配时,都有一个关联的 Sync Block Index (SBI)。当首次对这个对象使用 lock 时,SBI 被分配并指向操作系统内核中的一个真正的锁对象(比如 Windows 的 CRITICAL_SECTION)。

当锁已被占用时,后续请求的线程会进入内核等待状态,发生上下文切换。

Monitor.Wait(object obj), Monitor.Pulse(object obj), Monitor.PulseAll(object obj) 提供了在锁内等待特定条件成立的能力(类似 ConditionVariable),可用于构建生产者-消费者模式等。

操作方式

lock语句是使用Monitor的简便方式:

private readonly object _lock = new object();
lock (_lock)
{
    // 临界区代码
}

等价于:

Monitor.Enter(_lock);
try
{
    // 临界区代码
}
finally
{
    Monitor.Exit(_lock);
}

应用场景

  • 保护共享变量或非线程安全的集合
  • 确保单一线程修改资源,如更新计数器或列表
  • 需要简单互斥的临界区
  • 临界区执行时间相对较长(大于上下文切换开销)
  • 锁竞争不是极端激烈

最佳实践

  • 使用私有对象(如private readonly object _lock = new object();)进行锁定,避免死锁。
  • 保持临界区尽可能短,减少锁竞争。
  • 避免锁定公共对象或类型(如typeof(MyClass)),因为其他代码可能也会锁定它们。
  • 不要在锁内调用不可控的外部代码,可能导致死锁。

优点

  • 使用简单,lock语句语法直观。
  • 对于短临界区效率较高。
  • Monitor 锁是可重入(Reentrancy)的。同一个线程可以多次获得同一个锁对象上的锁(进入嵌套的 lock 块)。计数器会增加,只有等计数器归零时锁才会被释放。

缺点

  • 可能导致死锁,如果锁使用不当。Monitor.TryEnter(object obj, int timeoutMilliseconds) 允许设置等待超时,是避免死锁的重要手段。
  • 不支持多读单写场景。
  • .NET 的 Monitor 锁是非公平的(Windows CLR 实现)。当锁释放时,操作系统从等待队列中选择下一个唤醒的线程是不确定的,不一定是最早等待的那个(这有助于提高吞吐量,但可能导致某些线程“饥饿”)。

2. System.Threading.Lock

原理

System.Threading.Lock是.NET 9(C# 13)引入的新同步原语,旨在提供比Monitor更高效的互斥锁机制。它通过EnterScope方法支持using语句,确保锁自动释放,降低死锁风险。

操作方式

直接使用:

private readonly Lock _lock = new Lock();
using (_lock.EnterScope())
{
    // 临界区代码
}

或在C# 13及以上版本中使用lock语句:

lock (_lock)
{
    // 临界区代码
}

应用场景

  • Monitor类似,用于保护共享资源。
  • 适用于需要高性能的场景,如高并发系统。

最佳实践

  • 使用私有Lock实例。
  • 利用using语句确保锁自动释放。
  • 避免将Lock对象转换为object或其他类型,以防止编译器警告。

优点

  • 性能比Monitor高约25%。
| Method                   | Mean      | Error    | StdDev   | Ratio | Gen0   | Allocated | Alloc Ratio |
|————————- |———-:|———:|———:|——:|——-:|———-:|————😐
| CountTo1000WithLock      | 107.22 us | 1.561 us | 1.460 us |  1.00 | 0.1221 |   1.06 KB |        1.00 |
| CountTo1000WithLockClass |  75.73 us | 0.884 us | 0.827 us |  0.71 | 0.1221 |   1.05 KB |        0.99 |
  • 使用Dispose模式自动释放锁,降低死锁风险。
  • lock语句无缝集成,语法简洁。

缺点

  • 需要.NET 9或更高版本。
  • 开发者对其熟悉度较低。

3. Mutex

原理

  • Mutex(互斥锁)是一种支持进程间同步的互斥锁机制,确保只有一个线程或进程访问共享资源。
  • 可以通过命名互斥锁实现跨进程同步。
  • 比 Monitor/lock 重得多(涉及系统调用)。
  • 支持安全访问系统资源(如文件、硬件设备句柄)。

操作方式

private static Mutex _mutex = new Mutex();
_mutex.WaitOne();
// 临界区代码
_mutex.ReleaseMutex();

应用场景

  • 跨进程同步,如确保应用程序的单一实例运行。
  • 保护共享资源,如文件或数据库。

最佳实践

  • 使用命名互斥锁(如new Mutex(false, "MyAppMutex"))进行进程间同步。
  • 尽快释放互斥锁,减少阻塞时间。

注意

  • 重入性:命名 Mutex 默认是可重入的(同一个线程)。匿名(未命名)Mutex 在 .NET Framework 默认可重入,在 .NET Core+ 中默认为 .NoRecursion 行为。
  • 自动释放:如果持有 Mutex 的线程终止(例如崩溃),操作系统会自动释放锁(这可能导致程序逻辑错误),并且下一个等待的线程可能接收到 AbandonedMutexException。

优点

  • 支持进程间同步。
  • 提供可靠的互斥访问。

缺点

  • 由于涉及内核模式转换,性能较低。
  • 开销较大,不适合高频短临界区。

4. SpinLock

原理

SpinLock是一种互斥锁,线程在尝试获取锁时会通过自旋(循环检查)等待锁可用,适用于极短的临界区。

操作方式

private SpinLock _spinLock = new SpinLock();
bool lockTaken = false;
try
{
    _spinLock.Enter(ref lockTaken);
    // 临界区代码
}
finally
{
    if (lockTaken)
    {
        _spinLock.Exit();
    }
}

应用场景

  • 极短的临界区,锁持有时间短于上下文切换成本。
  • 高并发场景,锁竞争频繁但持续时间短。

最佳实践

  • 仅用于极短临界区。
  • 避免在低竞争或长临界区场景中使用。

优点

  • 对于短临界区开销低。
  • 无上下文切换。

缺点

  • 如果锁持有时间长,会浪费CPU周期。
  • 不适合长临界区。

5. ReaderWriterLockSlim

原理

ReaderWriterLockSlim允许多个线程同时读取资源,但写操作互斥,且写时不允许读操作,适合读多写少的场景。

有几种不同的锁定模式:

  • 读取锁 (Read Lock):共享模式,允许多个线程同时持有。
  • 写入锁 (Write Lock):独占模式,一旦持有,排斥所有读取锁和其他写入锁。
  • 可升级读取锁 (Upgradeable Read Lock):一种特殊模式,允许一个读取线程在持有读锁的同时,后续有需要时可以原子性地升级 (Upgrade)为写入锁(避免先释放读锁再尝试拿写锁过程中出现竞态或死锁)

操作方式

private readonly ReaderWriterLockSlim _rwLock = new ReaderWriterLockSlim();
public string ReadData()
{
    _rwLock.EnterReadLock(); // 获取读锁
    try
    {
        // 安全读取共享数据
        return _cachedData;
    }
    finally
    {
        _rwLock.ExitReadLock(); // 释放读锁
    }
}
public void UpdateData(string newData)
{
    _rwLock.EnterWriteLock(); // 获取写锁
    try
    {
        // 安全更新共享数据
        _cachedData = newData;
    }
    finally
    {
        _rwLock.ExitWriteLock(); // 释放写锁
    }
}
// 使用可升级锁 (避免“写者饥饿”风险):
public void UpdateIfCondition(string newData, Func<bool> condition)
{
    _rwLock.EnterUpgradeableReadLock(); // 获取可升级读锁
    try
    {
        if (condition())
        {
            _rwLock.EnterWriteLock(); // 升级为写锁
            try
            {
                // 安全更新共享数据
                _cachedData = newData;
            }
            finally
            {
                _rwLock.ExitWriteLock(); // 降级回可升级读锁
            }
        }
    }
    finally
    {
        _rwLock.ExitUpgradeableReadLock(); // 释放锁
    }
}

应用场景

  • 读操作频繁、写操作较少的场景,如缓存系统。

最佳实践

  • 确保写操作快速,减少读线程阻塞。
  • 避免长时间持有写锁,防止写者饥饿。

注意

  • ReaderWriterLockSlim 性能更好,语义更清晰,设计更合理。强烈建议总是使用 ReaderWriterLockSlim 而不是 ReaderWriterLock。
  • 性能特征:在纯读场景下并发度接近无锁;写操作开销比普通互斥锁略高(需要管理读写状态转换);升级操作开销适中。
  • 公平性与策略:提供了构造参数 LockRecursionPolicy.NoRecursion / .SupportsRecursion 和 ReaderWriterLockSlim(lockRecursionPolicy) 来控制递归行为。也涉及公平性问题(如读者优先或写者优先,ReaderWriterLockSlim 有机制防止写者饿死)。

优点

  • 允许多个线程同时读取,提高性能。
  • 适合读多写少场景。

缺点

  • 使用复杂,需管理读写锁状态。不恰当地嵌套获取不同类型的锁(特别是尝试升级锁失败时等待其他锁)会导致死锁。
  • 可能导致写者饥饿。

6. Semaphore 和 SemaphoreSlim

原理

  • Semaphore控制对资源池的并发访问,限制同时访问的线程数。
  • Semaphore:内核模式,支持跨进程、命名。
  • SemaphoreSlim:轻量级用户模式实现(必要时退化到内核),仅进程内有效,性能开销远小于 Semaphore。绝大多数进程内场景应优先使用 SemaphoreSlim。
  • SemaphoreSlim 默认使用公平队列(FIFO),有助于防止饥饿。Semaphore 的公平性由操作系统决定。

操作方式

private Semaphore _semaphore = new Semaphore(33); // 初始和最大计数
//WaitOne/WaitAsync:尝试获取一个令牌(信号)。若无可用令牌则阻塞/异步等待
_semaphore.WaitOne();
// Release:释放一个令牌
_semaphore.Release();

SemaphoreSlim使用方式类似。

应用场景

  • 限制并发访问特定资源的数量(API调用限流、连接池控制、异步任务并发度控制)。

最佳实践

  • 使用Semaphore进行进程间同步,SemaphoreSlim用于进程内。
  • 设置合理的初始和最大计数。

优点

  • 灵活控制并发级别。
  • SemaphoreSlim性能较高。

缺点

  • 使用较复杂。
  • 可能导致死锁。

7. EventWaitHandle、AutoResetEvent、ManualResetEvent、ManualResetEventSlim

原理

事件用于线程间信号传递。AutoResetEvent在信号一个等待线程后自动重置;ManualResetEvent保持信号状态直到手动重置;ManualResetEventSlim是轻量级版本。

操作方式

AutoResetEvent示例:

private AutoResetEvent _event = new AutoResetEvent(false);
_event.WaitOne(); // 等待信号
// 执行操作
_event.Set(); // 发送信号

ManualResetEvent示例:

private ManualResetEvent _event = new ManualResetEvent(false);
_event.WaitOne(); // 等待信号
// 执行操作
_event.Set(); // 发送信号
_event.Reset(); // 重置事件

应用场景

  • 生产者-消费者模式。
  • 等待特定任务完成。
  • 启动/停止信号广播、一次性初始化完成指示。

最佳实践

  • 使用AutoResetEvent进行一对一信号传递。
  • 使用ManualResetEvent广播信号给多个线程。

优点

  • 提供简单的信号传递机制。

缺点

  • 状态管理复杂,尤其是ManualResetEvent

8. CountdownEvent

原理

初始化一个计数(N)。线程调用 Signal() 来递减计数。当计数达到0时,所有在该对象上 Wait() 的线程被释放。适用于“N个任务完成后继续”的场景。

操作方式

private CountdownEvent _countdown = new CountdownEvent(3);
_countdown.Wait(); // 等待计数归零
// 执行操作
_countdown.Signal(); // 减少计数

应用场景

  • 主线程等待一组分散操作的完成,模拟部分 Task.WaitAll 效果但有更多控制(可在操作执行过程中动态调整计数)。

最佳实践

  • 设置正确的初始计数。
  • 确保所有信号都发送,避免死锁。

优点

  • 便于等待多个事件。

缺点

  • 仅限于计数场景。

9. Barrier

原理

允许多个线程分阶段执行任务,并确保所有参与线程在一个共同的屏障点(Phase)同步汇合(都到达后)才能继续下一阶段。

操作方式

private Barrier _barrier = new Barrier(3);
_barrier.SignalAndWait(); // 信号并等待其他线程
// 继续执行

应用场景

  • 并行算法中协调多个线程的阶段,如分治算法、复杂数据并行流水线处理。

最佳实践

  • 确保所有参与者调用SignalAndWait

优点

  • 协调多线程分阶段执行。

缺点

  • 设置复杂,需确保所有线程参与。

10. SpinWait

原理

SpinWait通过自旋等待条件成立,适合短时间等待。

操作方式

SpinWait.SpinUntil(() => someCondition);

应用场景

  • 短时间等待条件成立,如检查标志位。

最佳实践

  • 用于预期很快满足的条件。
  • 避免长时间自旋。

优点

  • 避免上下文切换。

缺点

  • 长时间等待浪费CPU资源。

11. 无锁替代

  • 不可变性 (Immutability):一旦创建对象就不可修改。避免了修改引起的同步需求(readonly 字段,记录类型 record)。
  • 线程本地存储 (Thread-Local Storage – TLS):ThreadStaticAttribute, AsyncLocal 变量,ThreadLocal。每个线程使用自己独立的数据副本(适用性有限)。
  • Interlocked 类:提供对简单类型(int, long, IntPtr, float, double, object 引用)执行原子操作的静态方法(Increment, Decrement, Add, Exchange, CompareExchange)。是最轻量级的“锁”,基于 CPU 的原子指令实现,性能极高,无锁开销。
    private int _counter = 0;
    public void IncrementSafely()
    {
        Interlocked.Increment(ref _counter); // 原子+1
    }
    public void SetIfEqual(int newValue, int expected)
    {
        Interlocked.CompareExchange(ref _counter, newValue, expected); // CAS
    }
  • 基于任务的异步模式 (TAP) 与 Task:
    • Channel (System.Threading.Channels):.NET Core 2.1+ 引入。高性能、无锁/有界可选的生产者-消费者队列替代方案(取代 BlockingCollection 和无锁队列手动实现)。支持单/多生产者、单/多消费者。是编写异步管道、处理背压 (Backpressure) 的首选。
    var channel = Channel.CreateUnbounded<T>();
    // 生产者
    await channel.Writer.WriteAsync(item);
    // 消费者
    while (await channel.Reader.WaitToReadAsync())
        while (channel.Reader.TryRead(out var item)) { … }
    • ValueTask / IValueTaskSource:Task 的轻量级替代(减少了堆分配),尤其在同步完成路径上优化显著。
  • Immutable Collections (System.Collections.Immutable):提供线程安全的不可变集合,通过原子替换整个集合引用来“修改”数据。读操作非常高效(无需锁),写操作创建新集合,适合读远多于写的共享数据。
  • 专为并发访问设计的内置集合:
    • ConcurrentDictionary<TKey, TValue>:高效、低锁竞争、可并行的字典。
    • ConcurrentQueue / ConcurrentStack:先进先出(FIFO) / 后进先出(LIFO)队列,基于CAS实现,避免锁争用。
    • BlockingCollection:有界/无界生产者-消费者队列(底层使用 ConcurrentQueue 等),提供 Take() 阻塞语义(Channel 通常是更好的异步选择)。支持优雅取消和完成通知。

12. 结语

选择合适的同步原语取决于应用程序需求,如是否需要进程间同步、读写分离或高性能。System.Threading.Lock是C# 13 中的新选择,性能优于Monitor,适合大多数互斥场景。开发者应根据场景权衡性能、复杂性和功能,确保线程安全的同时避免死锁和性能瓶颈。

13. 附件表格对比

同步原语
互斥性
允许多读
进程间支持
性能
示例用例
是否支持可重入
Monitor
保护共享变量
System.Threading.Lock
极高
高性能互斥锁
Mutex
进程间同步
SpinLock
极高
极短临界区
ReaderWriterLockSlim
是(写)
读多写少资源
Semaphore
限制并发访问
SemaphoreSlim
进程内并发控制
EventWaitHandle
线程/进程间信号传递
ManualResetEventSlim
进程内信号传递
CountdownEvent
等待多个信号
Barrier
分阶段线程执行
Interlocked
极高
原子操作
SpinWait
短时间自旋等待

转载请注明:SuperIT » C#的各种锁机制

喜欢 (0)or分享 (0)

您必须 登录 才能发表评论!