基于IDisposable模式的C#共享资源访问同步方案可靠性及潜在风险咨询
这是个相当巧妙的思路!用IDisposable配合using语句来自动管理锁的生命周期,确实能简化代码结构——不用再把临界区代码缩进在lock块里,一行using var token就能搞定。咱们来逐个解答你的疑问,再聊聊潜在的坑:
1. 异常情况下,Dispose是否会被可靠调用?
完全没问题!using语句在编译时会被自动转换成try/finally结构,不管你在using覆盖的代码里是正常执行、抛出异常,还是用return/break提前跳出,finally块里的Dispose方法一定会执行。换句话说,你的Monitor.Exit肯定会被调用,不会出现锁泄漏的情况。
比如你写的:
using var token = TheResource.WantAccess(); // 临界区代码
编译后等效于:
IDisposable token = TheResource.WantAccess(); try { // 临界区代码 } finally { token.Dispose(); }
这种机制是C#语言层面保证的,非常可靠。
2. 这个方案整体是否值得信赖?
核心逻辑是靠谱的,但有几个潜在陷阱需要注意,否则可能会踩坑:
潜在陷阱1:重入锁的问题
如果同一个线程多次调用WantAccess()(比如方法A调用WantAccess()后,又调用了方法B,方法B也调用了WantAccess()),会导致多次Monitor.Enter,但每个AccessToken只会执行一次Monitor.Exit,最终锁的计数会不匹配——当最后一个AccessToken被释放时,Monitor.Exit会抛出SynchronizationLockException,因为当前线程已经不再持有锁了。
解决办法是给SynchronizedAccessBase加上重入计数逻辑,比如用[ThreadStatic]变量跟踪当前线程的重入次数,AcquireLock时增加计数,ReleaseLock时减少计数,只有当计数归零时才调用Monitor.Exit。
潜在陷阱2:冗余且危险的终结器
你的AccessToken里写了终结器(~AccessToken()),但实际上完全没必要:
using语句已经确保Dispose被调用,而且Dispose里调用了GC.SuppressFinalize(this),终结器几乎不会被执行;- 退一万步说,如果终结器真的被执行了,它是在GC线程上跑的,而不是持有锁的线程,这时候调用
Monitor.Exit会直接抛出异常(因为GC线程没持有锁)。
所以直接删掉终结器就好,反而更安全。
潜在陷阱3:锁的粒度问题
你的方案里,锁是绑定在SynchronizedAccessBase实例上的,也就是说所有访问该实例的操作都共用一把锁。如果你的资源里有多个可以独立访问的子资源(比如一个类里有两个独立的计数器),这种粗粒度锁会不必要地降低并发性能。当然如果你的场景就是需要整个资源的独占访问,那这个问题不大,但如果要细粒度控制,这个方案就得调整。
潜在陷阱4:可读性的权衡
虽然你追求的是“无需缩进代码块”的简洁性,但其他维护者可能不容易一眼看出这行using var token对应的是哪个资源的锁,以及锁的覆盖范围是整个方法还是部分代码。对比传统的lock块:
lock (TheResource.syncObj) { // 临界区代码 }
缩进明确标出了临界区,可读性更强。所以要根据团队的编码习惯,权衡简洁性和可读性。
潜在陷阱5:无法支持高级同步操作
如果后续你的场景需要用到Monitor.Wait/Monitor.Pulse这类等待-通知机制,当前方案就无法满足——因为这些方法需要在持有锁的线程上调用,但你的AccessToken没有暴露这些能力。如果要支持,就得扩展AccessToken的接口,但会增加方案的复杂度。
优化后的代码示例(去掉终结器+支持重入)
#define WITH_CONSOLE_LOG //测试长循环时建议取消定义WITH_CONSOLE_LOG public abstract class SynchronizedAccessBase { private readonly object syncObj = new(); [ThreadStatic] private static int _reentryCount; private class AccessToken : IDisposable { private readonly SynchronizedAccessBase _parent; private bool _didDispose; public AccessToken(SynchronizedAccessBase parent) { _parent = parent; } protected virtual void Dispose(bool disposing) { if (!_didDispose) { _parent.ReleaseLock(); #if WITH_CONSOLE_LOG Console.WriteLine("Monitor.Exit by Thread=" + Thread.CurrentThread.ManagedThreadId); #endif _didDispose = true; } } void IDisposable.Dispose() { Dispose(disposing: true); GC.SuppressFinalize(this); } } public IDisposable WantAccess() { AcquireLock(); #if WITH_CONSOLE_LOG Console.WriteLine("Monitor.Enter by Thread=" + Thread.CurrentThread.ManagedThreadId); #endif return new AccessToken(this); } private void AcquireLock() { if (Thread.CurrentThread == Monitor.GetOwner(syncObj)) { _reentryCount++; } else { Monitor.Enter(syncObj); _reentryCount = 1; } } private void ReleaseLock() { if (_reentryCount > 1) { _reentryCount--; } else { Monitor.Exit(syncObj); _reentryCount = 0; } } } public class MyResource : SynchronizedAccessBase { public int Value; } private MyResource TheResource; private void MyMethod() { using var token = TheResource.WantAccess(); #if WITH_CONSOLE_LOG Console.WriteLine("Inc'ing Value by Thread=" + Thread.CurrentThread.ManagedThreadId); #endif TheResource.Value++; } void Main() { this.TheResource = new MyResource(); var inc_tasks = new Task[10]; for (int i = 0; i < inc_tasks.Length; i++) inc_tasks[i] = Task.Run(() => { for (int loop = 1; loop <= 100; loop++) MyMethod(); }); Task.WaitAll(inc_tasks); Console.WriteLine("End of Main() with Value==" + TheResource.Value); }
总的来说,这个方案在你的目标场景(简单的独占资源访问,追求代码简洁)下是完全可靠的,只要避开上述的潜在陷阱,就能稳定工作。
内容的提问来源于stack exchange,提问作者KarloX

