惊喜:你可以‘拦截’C# lock 语句
Source: Dev.to

你可以在 C# 中“劫持” lock。下面是它的工作原理,以及为什么 不应该这么做。
很多开发者认为 lock 是某种运行时的“魔法”。其实并不是。在大多数情况下,它只是语法糖:编译器会把它重写为对 System.Threading.Monitor 的调用。
这种重写存在一个丑陋的边缘情况。如果你的项目定义了自己的 System.Threading.Monitor,编译器可能会绑定到你的类型,而不是 BCL 中的那个。换句话说,你可以改变 lock 的含义。
这是一种派对技巧,也是一把脚枪。请把它当作警示故事,而 不是一种技术手段。
注意: 从 .NET 9 和 C# 13 开始,当表达式是
System.Threading.Lock时,lock有一个特殊的快速路径。在这种情况下,它会编译为using (x.EnterScope()) { … },而不是Monitor.Enter/Exit。
下面展示的劫持方式适用于经典的lock (object)路径。
编译器为 lock 生成的代码
对于经典的 lock (obj) { … } 情形,C# 编译器会生成等价于以下代码的实现:
object _lockObj = obj;
bool _lockWasTaken = false;
try
{
System.Threading.Monitor.Enter(_lockObj, ref _lockWasTaken);
// Your code...
}
finally
{
if (_lockWasTaken) System.Threading.Monitor.Exit(_lockObj);
}
因此 lock 在运行时并不是“特殊”的,它是一种编译器模式,会展开为已知的调用。
劫持技巧
如果你定义一个与 BCL 类型完全限定名相同的类型,就可以让编译器调用 你的 方法。
最小示例(LINQPad 代码片段):
using System;
class Program
{
static readonly object _sync = new();
static void Main()
{
// 看起来像普通的 lock,但实际上被劫持了。
lock (_sync)
{
Console.WriteLine("Working inside the lock...");
}
}
}
// 我们“伪造” System 命名空间和类。
namespace System.Threading
{
public static class Monitor
{
public static void Enter(object obj, ref bool lockTaken)
{
Console.WriteLine("Hijacked: Enter() was called");
lockTaken = true; // 重要:否则 Exit() 不会被调用。
}
public static void Exit(object obj)
{
Console.WriteLine("Hijacked: Exit() was called");
}
}
}
运行它,你会看到 Hijacked: 的信息。这就证明编译器将 lock 绑定到了 你的 System.Threading.Monitor。
为什么会这样?
因为名称解析发生在 编译时。
你的编译单元中包含一个 System.Threading.Monitor 类型,同时在引用的程序集里也有一个。编译器看到这两个类型并选择其中一个。如果它选择了你的类型,重写仍然会发生,但它会针对你的方法。
这正是编译器警告想要告诉你的。
不要忽视警告
这段代码应该会产生 CS0436。在这种情况下,它并不是“噪声”;它正是重点所在。
CS0436 的含义是:
- 你的编译单元中的类型与导入的类型之间存在冲突
- 编译器将使用来自你的编译单元的类型
如果你自定义的 Enter 方法实际上并没有加锁,那么多个线程可能会同时进入所谓的临界区。这会破坏不变量,导致最糟糕的错误:难以复现且在调试器下会消失的罕见竞争。
这种情况也可能意外发生:
- 一个辅助类命名为
Monitor,却位于错误的命名空间 - 某个依赖项提供了冲突的类型
- 全局抑制了警告,导致
CS0436被忽略
如果你在 System.Threading.Monitor 周围看到 CS0436,请立即停下来进行调查。
更安全的目标:测量锁争用
如果你的目标是可观测性,而不是破坏,那么有一种更好的方法:在不触碰 lock 的情况下测量争用。
.NET 为监视器争用提供了运行时事件:
ContentionStart_V2– 事件 ID 81ContentionStop_V1– 事件 ID 91(包含DurationNs)- 关键字是
ContentionKeyword(0x4000)
你可以通过 EventListener 监听这些事件。
(LINQPad 代码片段):
using System;
using System.Diagnostics.Tracing;
using System.Threading;
sealed class LockMonitor : EventListener
{
public long TotalWaitTimeNs;
int _durationIndex = -1;
protected override void OnEventSourceCreated(EventSource source)
{
if (source.Name == "Microsoft-Windows-DotNETRuntime")
{
EnableEvents(source, EventLevel.Informational, (EventKeywords)0x4000);
}
}
protected override void OnEventWritten(EventWrittenEventArgs eventData)
{
// ContentionStop_V1
if (eventData.EventId != 91 || eventData.PayloadNames is null)
return;
if (_durationIndex < 0)
_durationIndex = eventData.PayloadNames.IndexOf("DurationNs");
if (_durationIndex < 0)
return;
// DurationNs is documented as a Double. Convert and keep nanoseconds as a long for easy accumulation.
var durationNs = (long)Convert.ToDouble(eventData.Payload![_durationIndex]!);
Interlocked.Add(ref TotalWaitTimeNs, durationNs);
}
}
上面的代码片段已故意截断,以匹配原始来源。
使用此方法可以获得的内容
lock仍然是一个真实的锁。- 您可以收集线程等待的时间。
- 您可以比较“好”和“坏”的代码路径,清晰地看到争用情况。
如果需要更深入的报告(每个锁对象、堆栈跟踪、时间线),请使用 dotnet‑trace、PerfView 或基于 ETW/EventPipe 的分析器。上面的 EventListener 示例是一个很好的最小演示。
要点
lock是编译器糖,通常会展开为System.Threading.Monitor.Enter/Exit。- 类型名称冲突可能会改变编译器生成的代码。CS0436 警告你正在改变语义。
- 劫持
lock是一个很巧妙的演示,但在实际代码中是极其糟糕的做法。 - 在诊断时,建议使用争用事件和合适的工具,而不是使用技巧。
