痨砖 发表于 2025-9-24 18:28:01

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

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

锑砖 发表于 20 小时前

懂技术并乐意极积无私分享的人越来越少。珍惜
页: [1]
查看完整版本: C# 锁机制全景与高效实践:从 Monitor 到 .NET 9 全新 Lock