You can “hijack” lock in C#. Here is how it works, and why you should not do it.
Many developers think lock is some kind of runtime “magic”. It is not. In most cases it is plain syntax sugar: the compiler rewrites it into calls to System.Threading.Monitor.
That rewrite has an ugly edge case. If your project defines its own System.Threading.Monitor, the compiler can bind to your type instead of the BCL one. In other words, you can change what lock means.
This is a party trick. Also a foot-gun. Treat it as a cautionary tale, not a technique.
Note: starting with .NET 9 and C# 13,
lockhas a special fast path when the expression is aSystem.Threading.Lock. In that case, it compiles tousing (x.EnterScope()) { ... }instead ofMonitor.Enter/Exit.
The hijack shown below applies to the classiclock (object)path.
What the compiler generates for lock
For the classic lock (obj) { ... } case, the C# compiler generates code equivalent to this:
object _lockObj = obj;
bool _lockWasTaken = false;
try
{
System.Threading.Monitor.Enter(_lockObj, ref _lockWasTaken);
// Your code...
}
finally
{
if (_lockWasTaken) System.Threading.Monitor.Exit(_lockObj);
}
So lock is not “special” at runtime. It is a compiler pattern that expands into known calls.
The hijack trick
If you define a type with the same fully-qualified name as the BCL type, you can make the compiler call your methods.
Minimal example:
- LINQPad snippet: https://share.linqpad.net/dmgs488o.linq
using System;
class Program
{
static readonly object _sync = new();
static void Main()
{
// Looks like a normal lock, but it is hijacked.
lock (_sync)
{
Console.WriteLine("Working inside the lock...");
}
}
}
// We "fake" the system namespace and class.
namespace System.Threading
{
public static class Monitor
{
public static void Enter(object obj, ref bool lockTaken)
{
Console.WriteLine("Hijacked: Enter() was called");
lockTaken = true; // Important: otherwise Exit() won't be called.
}
public static void Exit(object obj)
{
Console.WriteLine("Hijacked: Exit() was called");
}
}
}
Run it and you will see the Hijacked: messages. That is your proof that the compiler bound lock to your System.Threading.Monitor.
Why does this work?
Because name resolution happens at compile time.
Your compilation contains a System.Threading.Monitor type, and there is also one in referenced assemblies. The compiler sees both and chooses one. If it picks yours, the rewrite still happens, but it targets your methods.
This is exactly what the compiler warning is trying to tell you.
Do not ignore the warning
This code should produce CS0436. In this scenario it is not “noise”. It is the whole point.
CS0436 means:
- there is a conflict between a type in your compilation and an imported type
- the compiler will use the type from your compilation
If your custom Enter method does not actually lock, then multiple threads can run inside the supposed critical section at the same time. That breaks invariants and causes the worst kind of bugs: rare races that are hard to reproduce and disappear under the debugger.
It can also happen accidentally:
- a helper class is named
Monitorand ends up in the wrong namespace - a dependency ships a conflicting type
- warnings are suppressed globally and CS0436 is missed
If you ever see CS0436 around System.Threading.Monitor, stop and investigate.
A safer goal: measure lock contention
If your goal is observability, not sabotage, there is a better approach: measure contention without touching lock at all.
.NET exposes runtime events for monitor contention:
-
ContentionStart_V2has event id 81 -
ContentionStop_V1has event id 91 -
ContentionStop_V1includesDurationNs - the keyword is
ContentionKeyword(0x4000)
You can listen to these events via EventListener.
- LINQPad snippet: https://share.linqpad.net/6iebniko.linq
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);
}
}
What you get with this approach
-
lockremains a real lock. - You collect how much time threads spend waiting.
- You can compare “good” and “bad” code paths and see contention clearly.
If you need a deeper report (per lock object, stack traces, timelines), use tools like dotnet-trace, PerfView, or ETW/EventPipe-based profilers. The small EventListener above is a good minimal demo.
Takeaway
-
lockis compiler sugar that usually expands intoSystem.Threading.Monitor.Enter/Exit. - A type name collision can change what the compiler emits. CS0436 warns you that you are changing meaning.
- Hijacking
lockis a neat demo, but a terrible idea in real code. - For diagnostics, prefer contention events and proper tooling instead of tricks.
Top comments (0)