lock语句被编译为try-finally包裹的Monitor.Enter/Exit调用,确保异常时锁必释放;新版.NET默认用Monitor.Enter(obj, ref bool)保障原子性,旧版则依赖异常模拟。

lock 语句会被编译成 try-finally + Monitor.Enter/Exit
你写的 lock(obj) { ... } 不会原样保留,C# 编译器(csc)会在 IL 层面把它重写为显式的 Monitor.Enter 和 Monitor.Exit 调用,并包裹在 try...finally 块中。这是为了确保即使临界区抛出异常,锁也一定会被释放。
关键点:
-
Monitor.Enter的返回值会被检查,如果为false(表示未获取到锁),则不会进入try块,而是直接跳过整个临界区逻辑(但这种情况极少发生,通常只出现在带超时的重载里) - 标准
lock对应的是无超时版本的Monitor.Enter(Object),它不返回布尔值,所以编译器会用另一个重载:先调用Monitor.Enter(object, ref bool)(.NET Core 2.0+ / .NET 5+ 默认行为),或在旧版中插入额外逻辑模拟原子性 - 编译后的
finally块里,Monitor.Exit是无条件执行的,哪怕Enter失败也不会进finally
看一个具体反编译例子
假设你有这段 C# 代码:
object _lock = new object(); lock (_lock) { Console.WriteLine("in critical section"); }
用 ildasm 或 dotnet ilc 查看其 Release 模式编译后的 IL(简化后)大致如下:
.try { IL_0000: ldarg.0 IL_0001: ldfld object Test::<_lock>k__BackingField IL_0006: stloc.0 IL_0007: ldloc.0 IL_0008: call void [System.Runtime]System.Threading.Monitor::Enter(object) IL_000d: nop IL_000e: ldstr "in critical section" IL_0013: call void [System.Console]System.Console::WriteLine(string) IL_0018: nop IL_0019: leave.s IL_0025 } // end .try finally { IL_001b: ldloc.0 IL_001c: call void [System.Runtime]System.Threading.Monitor::Exit(object) IL_0021: nop IL_0022: endfinally } // end handler
注意:leave.s 是跳转到 finally 之后的指令,不是跳过 finally —— 这正是保证 Exit 总被执行的关键。
为什么不能手动写 Monitor.Enter/Exit 替代 lock
看似等价,但手动写容易出错:
- 漏掉
try-finally,导致异常时锁不释放 → 死锁风险 - 在
Enter后、try前就抛异常 →finally还没建立,Exit根本不会执行 - 误用
Monitor.TryEnter但忘记判断返回值,直接进临界区 → 逻辑错误 - .NET 6+ 中,
lock还可能被 JIT 优化为轻量级锁(如偏向锁、自旋锁)路径,而手写调用绕过了这些优化层
lock 编译行为在不同 .NET 版本有差异
主要区别在锁获取的“原子性保障”实现方式:
- .NET Framework 4.8 及更早:使用
Monitor.Enter(object)+ 隐式异常处理模拟成功标志 - .NET Core 2.0+ / .NET 5+:默认改用
Monitor.Enter(object, ref bool),由运行时保证调用本身是原子的,避免竞态条件 - 所有版本都强制生成
try-finally结构,这点没有例外
如果你在反编译时看到 ref bool 参数和两次 ldloca.s 指令,说明你正面对的是较新运行时的编译输出。这个细节常被忽略,但它直接影响锁失败时的行为可预测性。