DEV Community

Cover image for Surprise: You Can "Intercept" the C# lock Statement
Dmitry Dorogoy
Dmitry Dorogoy

Posted on

Surprise: You Can "Intercept" the C# lock Statement

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, lock has a special fast path when the expression is a System.Threading.Lock. In that case, it compiles to using (x.EnterScope()) { ... } instead of Monitor.Enter/Exit.

The hijack shown below applies to the classic lock (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);
}
Enter fullscreen mode Exit fullscreen mode

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:

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");
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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 Monitor and 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_V2 has event id 81
  • ContentionStop_V1 has event id 91
  • ContentionStop_V1 includes DurationNs
  • the keyword is ContentionKeyword (0x4000)

You can listen to these events via EventListener.

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);
    }
}
Enter fullscreen mode Exit fullscreen mode

What you get with this approach

  • lock remains 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

  • lock is compiler sugar that usually expands into System.Threading.Monitor.Enter/Exit.
  • A type name collision can change what the compiler emits. CS0436 warns you that you are changing meaning.
  • Hijacking lock is a neat demo, but a terrible idea in real code.
  • For diagnostics, prefer contention events and proper tooling instead of tricks.

Top comments (0)