C# 锁机制全景与高效实践:从 Monitor 到 .NET 9 全新 Lock
引言:线程安全与锁的基本概念
线程安全
在多线程编程中,保障共享资源的安全访问依赖于有效的线程同步机制。理解并处理好以下两个核心概念至关重要:
- 线程安全:指某个类、方法或数据结构能够在被多个线程同时访问或修改时,依然保持内部状态的一致性,并产生预期的结果。这通常意味着需要对共享状态(如全局变量、静态变量或对象实例字段)的并发访问进行有效管控,防止数据损坏或不一致性。
- 竞态条件 (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
的简便方式:
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(3, 3); // 初始和最大计数 | |
//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
|
否
|
无
|
否
|
高
|
短时间自旋等待
|
否
|
❝