为何锁内使用Task.Delay(1).Wait()时写线程更快获取锁?
这是个很有意思的线程调度问题,我们先拆解核心原因,再分析两种延迟的差异,最后给出更合理的替代方案。
为什么Task.Delay(1).Wait()让写线程更快拿到锁?
关键差异在于两种延迟操作对线程调度和锁竞争优先级的影响:
Thread.Sleep(1)的行为:
当重试线程在lock块内调用Thread.Sleep(1)时,线程会被操作系统挂起至少1ms(实际时长取决于系统时钟精度),且锁会一直被持有直到lock块结束。当continue执行后,锁被释放,但重试线程会立即再次尝试获取锁——由于线程刚从睡眠中唤醒,操作系统通常会优先调度它继续执行,导致写线程几乎没机会抢到锁,形成“锁饥饿”。Task.Delay(1).Wait()的行为:Task.Delay(1)基于系统定时器(IO完成端口)实现,而非直接阻塞线程。调用Wait()同步等待时,当前线程会被暂时释放回线程池,直到定时器触发。虽然lock块仍未结束(锁还被持有),但等待期间操作系统可以调度其他线程(比如写线程)运行。当定时器触发后,重试线程执行continue释放锁,此时写线程可能已经处于锁等待队列中,优先级更高,因此能更快获取锁。
另外,Task.Delay的等待逻辑会引入更多上下文切换,反而给了写线程抢占锁的机会——这和直觉相反,但实际是线程调度的非确定性导致的。
你的代码存在的问题
除了锁竞争的非确定性,当前实现还有几个隐患:
- 重试线程在
lock块内执行延迟操作,持有锁期间阻塞,会导致其他线程长时间无法访问dict,违背了锁“快速进出”的原则。 - 固定次数的重试逻辑不够灵活,容易出现无限循环或提前终止的情况。
- 使用
ManualResetEvent进行线程同步,不如现代的Task/Async-Await模式简洁。
替代方案
推荐使用异步重试+非阻塞锁/并发集合的组合,避免同步阻塞导致的锁竞争问题:
1. 使用AsyncLock实现异步锁(替代lock)
异步锁允许线程在等待锁时释放CPU资源,避免阻塞:
using Nito.AsyncEx; // 可通过NuGet安装Nito.AsyncEx private readonly AsyncLock _asyncLock = new AsyncLock(); private readonly Dictionary<int, string> _dict = new Dictionary<int, string>(); public async Task<bool> RetryLoopAsync(int x) { Console.WriteLine($"In Retry loop {x}"); for (int i = 0; i < 5; i++) { using (await _asyncLock.LockAsync()) { Console.WriteLine($"Entering Retry loop {x}. Count {i+1}"); if (_dict.ContainsKey(x)) { Console.WriteLine($"Exiting Retry loop {x}"); return true; } } // 延迟放在锁外面!避免持有锁时阻塞 await Task.Delay(1); } return false; }
2. 使用并发集合替代Dictionary
如果只是需要判断键是否存在并添加,ConcurrentDictionary更适合多线程场景,无需手动加锁:
private readonly ConcurrentDictionary<int, string> _dict = new ConcurrentDictionary<int, string>(); public async Task<bool> RetryLoopAsync(int x) { Console.WriteLine($"In Retry loop {x}"); for (int i = 0; i < 5; i++) { if (_dict.ContainsKey(x)) { Console.WriteLine($"Exiting Retry loop {x}"); return true; } await Task.Delay(1); } return false; } // TryAdd方法可以直接使用ConcurrentDictionary的TryAdd void TryAdd() { Console.WriteLine("Starting TryAdd"); // ... 前面的循环逻辑 Console.BackgroundColor = ConsoleColor.Red; Console.WriteLine("Exited loop"); Console.BackgroundColor = ConsoleColor.Black; if (_dict.TryAdd(21, "hello")) { Console.BackgroundColor = ConsoleColor.Red; Console.WriteLine("Added key successfully"); Console.BackgroundColor = ConsoleColor.Black; } }
3. 使用Task进行线程同步
替代ManualResetEvent,用async/await实现更简洁的线程同步:
public async Task RunAsync() { var retryTask = RetryThreadAsync(); var addTask = TryAddAsync(); bc.Add(20); bc.Add(21); await Task.WhenAll(retryTask, addTask); } public async Task RetryThreadAsync() { Console.WriteLine("Starting RetryLoop"); bool done = false; while (!done) { done = await RetryLoopAsync(21); } } public async Task TryAddAsync() { Console.WriteLine("Starting TryAdd"); for (int i = 0; i < 2000; i++) { if (i == 1500) break; await Task.Delay(1); // 用Task.Delay替代Thread.Sleep,避免阻塞线程 } // ... 后续添加逻辑 }
总结
你观察到的现象本质是线程调度的非确定性:Task.Delay(1).Wait()引入的线程池调度和上下文切换,意外给了写线程抢占锁的机会;而Thread.Sleep(1)让重试线程几乎垄断了锁资源。
最佳实践是避免在锁内执行阻塞操作,尽量使用异步模式和并发集合来减少锁竞争。
内容的提问来源于stack exchange,提问作者Lews Therin

