为什么 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);
<p>// 获取锁(阻塞式)
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;
<pre class='brush:php;toolbar:false;'>public FairLock() => _semaphore = new SemaphoreSlim(1, 1, true);
public async ValueTask<IDisposable> 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();
<p>await using (await lockObj.AcquireAsync())
{
// 临界区
}性能与兼容性注意事项
公平锁天然比非公平锁开销大:每次释放都要唤醒队首线程,且需维护等待队列节点。在低争用场景几乎无感,但在高频短临界区(如计数器递增)中,吞吐量可能下降 20–40%。
.NET Framework 4.7.2 及更早版本不支持SemaphoreSlim的
fairness参数(会忽略),必须升级到 .NET Core 2.0+ 或 .NET 5+ 若需跨平台一致性,避免混用
Monitor和
SemaphoreSlim实现同一逻辑 公平性只作用于“等待中的线程”,已进入临界区的线程不受影响;不要指望它解决死锁或嵌套锁顺序问题
真正需要公平锁的场景其实很少——多数时候是诊断出明确的饥饿问题后才引入。先确认争用模式,再决定是否值得为公平性牺牲一点吞吐。
