Monitor 和默认 lock 不是公平锁,因其底层依赖临界区或同步块,不保证等待顺序与获取顺序一致,可能导致线程饥饿;SemaphoreSlim(1,1,true) 是最轻量的公平锁实现,需注意版本兼容性与性能权衡。

为什么 Monitor 和默认 lock 不是公平锁
Monitor.Enter(即 C# 的 lock 语句)底层依赖 Windows 的临界区或 CLR 的同步块,**不保证线程获取锁的顺序与等待顺序一致**。多个线程竞争时,可能刚唤醒的线程被新来的线程“插队”,导致某些线程长期饥饿。这不是 bug,而是为吞吐量做的权衡。
- 没有 FIFO 队列机制,调度由 OS 决定,不可控
-
Monitor.TryEnter(int)超时返回 false 后,线程需自行重试,但重试时机无法对齐排队位置 - 即使在高争用下观察到“看似有序”,也不能当作公平性保障
用 SemaphoreSlim 手动构造公平锁
SemaphoreSlim 在 count = 1 且启用 fairness: true 时,内部使用 FIFO 等待队列(自 .NET Core 2.0+ / .NET 5+),是最轻量、最贴近需求的公平锁实现方式。
- 构造时必须传
true:new SemaphoreSlim(1, 1, true);省略第三个参数或传false就退化为非公平模式 -
Wait()会阻塞直到获得信号,WaitAsync()支持取消和异步等待 - 务必配对调用
Release(),否则锁永久泄露 —— 建议用try/finally或using(需封装为可释放包装类)
var fairLock = new SemaphoreSlim(1, 1, true);// 获取锁(阻塞式) fairLock.Wait(); try { // 临界区操作 } finally { fairLock.Release(); }
自定义 FairLock 类封装更安全的 API
直接暴露 SemaphoreSlim 容易漏掉 Release(),也缺乏语义表达。封装一层能强制资源管理,并隐藏公平性细节。
- 实现
IDisposable,支持using语法糖 - 构造函数只接受
fairness: true,避免误用非公平实例 - 内部用
WaitAsync+CancellationToken更适合现代异步场景 - 注意:不要在
Dispose()中调用异步方法,Release()是同步的
public sealed class FairLock : IDisposable
{
private readonly SemaphoreSlim _semaphore;
public FairLock() => _semaphore = new SemaphoreSlim(1, 1, true);
public async ValueTask AcquireAsync(CancellationToken ct = default)
{
await _semaphore.WaitAsync(ct);
return new Releaser(_semaphore);
}
private struct Releaser : IDisposable
{
private readonly SemaphoreSlim _semaphore;
public Releaser(SemaphoreSlim s) => _semaphore = s;
public void Dispose() => _semaphore.Release();
}
public void Dispose() => _semaphore?.Dispose();
}
使用示例:
var lockObj = new FairLock();
await using (await lockObj.AcquireAsync())
{
// 临界区
}
性能与兼容性注意事项
公平锁天然比非公平锁开销大:每次释放都要唤醒队首线程,且需维护等待队列节点。在低争用场景几乎无感,但在高频短临界区(如计数器递增)中,吞吐量可能下降 20–40%。
- .NET Framework 4.7.2 及更早版本不支持
SemaphoreSlim的fairness参数(会忽略),必须升级到 .NET Core 2.0+ 或 .NET 5+ - 若需跨平台一致性,避免混用
Monitor和SemaphoreSlim实现同一逻辑 - 公平性只作用于“等待中的线程”,已进入临界区的线程不受影响;不要指望它解决死锁或嵌套锁顺序问题
真正需要公平锁的场景其实很少——多数时候是诊断出明确的饥饿问题后才引入。先确认争用模式,再决定是否值得为公平性牺牲一点吞吐。










