引言:线程安全与锁的基本概念
线程安全
在多线程编程中,保障共享资源的安全访问依赖于有效的线程同步机制。理解并处理好以下两个核心概念至关重要:
- 线程安全:指某个类、方法或数据结构能够在被多个线程同时访问或修改时,依然保持内部状态的一致性,并产生预期的结果。这通常意味着需要对共享状态(如全局变量、静态变量或对象实例字段)的并发访问进行有效管控,防止数据损坏或不一致性。
- 竞态条件 (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 | 否 | 无 | 否 | 高 | 短时间自旋等待 | 否 |
❝
由于资料验证范围太广,难免会有遗漏,如果上述表格内的内容有问题,请在评论区告诉我