找回密码
 立即注册
首页 业界区 业界 C# 锁机制全景与高效实践:从 Monitor 到 .NET 9 全新 L ...

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

艋佰傧 2025-9-24 18:27:24
引言:线程安全与锁的基本概念

线程安全

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

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


  • 锁的本质:锁是一种同步工具,用于确保共享资源的互斥访问(一次只有一个线程使用)。当一个线程获得锁并执行被保护的代码段(临界区)时,其他试图获取同一锁的线程会被阻塞或等待,直到锁被释放。
  • 锁的目标:在保证正确性的前提下,最大化并发度和系统吞吐量,最小化延迟。
  • 锁的代价:

    • 阻塞开销:操作系统调度上下文切换的成本。
    • 自旋开销:忙等待消耗CPU周期。
    • 死锁风险:线程因相互等待对方释放锁而永久僵持。
    • 优先级反转:低优先级线程持有高优先级线程需要的锁。
    • 复杂性:使用不当可能导致程序难以理解和调试。
    • 选择锁的依据:临界区大小、等待时间长短、竞争激烈程度、读/写比例、进程边界、公平性要求等。

1. Monitor

原理

Monitor类提供了一种互斥锁机制,确保同一时间只有一个线程可以访问临界区。它是C#中lock语句的基础,通过Monitor.Enter和Monitor.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的简便方式:
  1. private readonly object _lock = new object();
  2. lock (_lock)
  3. {
  4.     // 临界区代码
  5. }
复制代码
等价于:
  1. Monitor.Enter(_lock);
  2. try
  3. {
  4.     // 临界区代码
  5. }
  6. finally
  7. {
  8.     Monitor.Exit(_lock);
  9. }
复制代码
应用场景


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


  • 使用私有对象(如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语句,确保锁自动释放,降低死锁风险。
操作方式

直接使用:
  1. private readonly Lock _lock = new Lock();
  2. using (_lock.EnterScope())
  3. {
  4.     // 临界区代码
  5. }
复制代码
或在C# 13及以上版本中使用lock语句:
  1. lock (_lock)
  2. {
  3.     // 临界区代码
  4. }
复制代码
应用场景


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


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


  • 性能比Monitor高约25%。
  1. | Method                   | Mean      | Error    | StdDev   | Ratio | Gen0   | Allocated | Alloc Ratio |
  2. |------------------------- |----------:|---------:|---------:|------:|-------:|----------:|------------:|
  3. | CountTo1000WithLock      | 107.22 us | 1.561 us | 1.460 us |  1.00 | 0.1221 |   1.06 KB |        1.00 |
  4. | 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 重得多(涉及系统调用)。
  • 支持安全访问系统资源(如文件、硬件设备句柄)。
操作方式
  1. private static Mutex _mutex = new Mutex();
  2. _mutex.WaitOne();
  3. // 临界区代码
  4. _mutex.ReleaseMutex();
复制代码
应用场景


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


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


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


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


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

原理

SpinLock是一种互斥锁,线程在尝试获取锁时会通过自旋(循环检查)等待锁可用,适用于极短的临界区。
操作方式
  1. private SpinLock _spinLock = new SpinLock();
  2. bool lockTaken = false;
  3. try
  4. {
  5.     _spinLock.Enter(ref lockTaken);
  6.     // 临界区代码
  7. }
  8. finally
  9. {
  10.     if (lockTaken)
  11.     {
  12.         _spinLock.Exit();
  13.     }
  14. }
复制代码
应用场景


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


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


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


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

原理

ReaderWriterLockSlim允许多个线程同时读取资源,但写操作互斥,且写时不允许读操作,适合读多写少的场景。
有几种不同的锁定模式:

  • 读取锁 (Read Lock):共享模式,允许多个线程同时持有。
  • 写入锁 (Write Lock):独占模式,一旦持有,排斥所有读取锁和其他写入锁。
  • 可升级读取锁 (Upgradeable Read Lock):一种特殊模式,允许一个读取线程在持有读锁的同时,后续有需要时可以原子性地升级 (Upgrade)为写入锁(避免先释放读锁再尝试拿写锁过程中出现竞态或死锁)
操作方式
  1. private readonly ReaderWriterLockSlim _rwLock = new ReaderWriterLockSlim();
  2. public string ReadData()
  3. {
  4.     _rwLock.EnterReadLock(); // 获取读锁
  5.     try
  6.     {
  7.         // 安全读取共享数据
  8.         return _cachedData;
  9.     }
  10.     finally
  11.     {
  12.         _rwLock.ExitReadLock(); // 释放读锁
  13.     }
  14. }
  15. public void UpdateData(string newData)
  16. {
  17.     _rwLock.EnterWriteLock(); // 获取写锁
  18.     try
  19.     {
  20.         // 安全更新共享数据
  21.         _cachedData = newData;
  22.     }
  23.     finally
  24.     {
  25.         _rwLock.ExitWriteLock(); // 释放写锁
  26.     }
  27. }
  28. // 使用可升级锁 (避免“写者饥饿”风险):
  29. public void UpdateIfCondition(string newData, Func<bool> condition)
  30. {
  31.     _rwLock.EnterUpgradeableReadLock(); // 获取可升级读锁
  32.     try
  33.     {
  34.         if (condition())
  35.         {
  36.             _rwLock.EnterWriteLock(); // 升级为写锁
  37.             try
  38.             {
  39.                 // 安全更新共享数据
  40.                 _cachedData = newData;
  41.             }
  42.             finally
  43.             {
  44.                 _rwLock.ExitWriteLock(); // 降级回可升级读锁
  45.             }
  46.         }
  47.     }
  48.     finally
  49.     {
  50.         _rwLock.ExitUpgradeableReadLock(); // 释放锁
  51.     }
  52. }
复制代码
应用场景


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


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


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


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


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

原理


  • Semaphore控制对资源池的并发访问,限制同时访问的线程数。
  • Semaphore:内核模式,支持跨进程、命名。
  • SemaphoreSlim:轻量级用户模式实现(必要时退化到内核),仅进程内有效,性能开销远小于 Semaphore。绝大多数进程内场景应优先使用 SemaphoreSlim。
  • SemaphoreSlim 默认使用公平队列(FIFO),有助于防止饥饿。Semaphore 的公平性由操作系统决定。
操作方式
  1. private Semaphore _semaphore = new Semaphore(3, 3); // 初始和最大计数
  2. //WaitOne/WaitAsync:尝试获取一个令牌(信号)。若无可用令牌则阻塞/异步等待
  3. _semaphore.WaitOne();
  4. // Release:释放一个令牌
  5. _semaphore.Release();
复制代码
SemaphoreSlim使用方式类似。
应用场景


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


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


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


  • 使用较复杂。
  • 可能导致死锁。
7. EventWaitHandle、AutoResetEvent、ManualResetEvent、ManualResetEventSlim

原理

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

AutoResetEvent示例:
  1. private AutoResetEvent _event = new AutoResetEvent(false);
  2. _event.WaitOne(); // 等待信号
  3. // 执行操作
  4. _event.Set(); // 发送信号
复制代码
ManualResetEvent示例:
  1. private ManualResetEvent _event = new ManualResetEvent(false);
  2. _event.WaitOne(); // 等待信号
  3. // 执行操作
  4. _event.Set(); // 发送信号
  5. _event.Reset(); // 重置事件
复制代码
应用场景


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


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


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


  • 状态管理复杂,尤其是ManualResetEvent。
8. CountdownEvent

原理

初始化一个计数(N)。线程调用 Signal() 来递减计数。当计数达到0时,所有在该对象上 Wait() 的线程被释放。适用于“N个任务完成后继续”的场景。
操作方式
  1. private CountdownEvent _countdown = new CountdownEvent(3);
  2. _countdown.Wait(); // 等待计数归零
  3. // 执行操作
  4. _countdown.Signal(); // 减少计数
复制代码
应用场景


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


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


  • 便于等待多个事件。
缺点


  • 仅限于计数场景。
9. Barrier

原理

允许多个线程分阶段执行任务,并确保所有参与线程在一个共同的屏障点(Phase)同步汇合(都到达后)才能继续下一阶段。
操作方式
  1. private Barrier _barrier = new Barrier(3);
  2. _barrier.SignalAndWait(); // 信号并等待其他线程
  3. // 继续执行
复制代码
应用场景


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


  • 确保所有参与者调用SignalAndWait。
优点


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


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

原理

SpinWait通过自旋等待条件成立,适合短时间等待。
操作方式
  1. 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 的原子指令实现,性能极高,无锁开销。
    1. private int _counter = 0;
    2. public void IncrementSafely()
    3. {
    4.     Interlocked.Increment(ref _counter); // 原子+1
    5. }
    6. public void SetIfEqual(int newValue, int expected)
    7. {
    8.     Interlocked.CompareExchange(ref _counter, newValue, expected); // CAS
    9. }
    复制代码
  • 基于任务的异步模式 (TAP) 与 Task:

    • Channel (System.Threading.Channels):.NET Core 2.1+ 引入。高性能、无锁/有界可选的生产者-消费者队列替代方案(取代 BlockingCollection 和无锁队列手动实现)。支持单/多生产者、单/多消费者。是编写异步管道、处理背压 (Backpressure) 的首选。
    1. var channel = Channel.CreateUnbounded<T>();
    2. // 生产者
    3. await channel.Writer.WriteAsync(item);
    4. // 消费者
    5. while (await channel.Reader.WaitToReadAsync())
    6.     while (channel.Reader.TryRead(out var item)) { ... }
    复制代码

    • ValueTask / IValueTaskSource:Task 的轻量级替代(减少了堆分配),尤其在同步完成路径上优化显著。

  • Immutable Collections (System.Collections.Immutable):提供线程安全的不可变集合,通过原子替换整个集合引用来“修改”数据。读操作非常高效(无需锁),写操作创建新集合,适合读远多于写的共享数据。
  • 专为并发访问设计的内置集合:

    • ConcurrentDictionary:高效、低锁竞争、可并行的字典。
    • 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否无否高短时间自旋等待否
由于资料验证范围太广,难免会有遗漏,如果上述表格内的内容有问题,请在评论区告诉我

来源:程序园用户自行投稿发布,如果侵权,请联系站长删除
免责声明:如果侵犯了您的权益,请联系站长,我们会及时删除侵权内容,谢谢合作!

相关推荐

您需要登录后才可以回帖 登录 | 立即注册