Decompiling the new lock: when the compiler stops calling Monitor
For twenty years lock (obj) lowered to Monitor.Enter/Exit with a lockTaken flag. C# 13 changed that: lock on a System.Threading.Lock lowers to EnterScope() and a Scope ref struct disposed in a finally — a completely different IL shape. Here is exactly what each form generates, how a decompiler tells them apart and folds both back into lock, and the one decompile tell that exposes a silent bug: a Lock boxed to object that quietly falls back to Monitor.
For two decades lock (obj) meant exactly one thing in IL: Monitor.Enter on the way in, a guarded Monitor.Exit in a finally on the way out, with a lockTaken flag threaded between them to make the acquire/release pair exception-safe. If you have decompiled any concurrent C#, that shape is burned into your memory. C# 13 and .NET 9 added System.Threading.Lock, a dedicated lock type with its own fast path — and locking on it lowers to something that looks nothing like Monitor. Same keyword, completely different generated code.
This matters when you read a decompiled assembly, because the IL now tells you which kind of lock you are looking at, and one of the possibilities is a latent bug the source hid from you. Let us walk both lowerings and see how a decompiler pulls them apart.
The classic lowering, for reference
Lock on a plain object and nothing has changed:
private readonly object _gate = new object();
void Add(int x)
{
lock (_gate) { _items.Add(x); }
}
The compiler lowers this to the familiar Monitor pattern. Decompiled without lock recognition, it reads:
object obj = _gate;
bool lockTaken = false;
try
{
System.Threading.Monitor.Enter(obj, ref lockTaken);
_items.Add(x);
}
finally
{
if (lockTaken)
System.Threading.Monitor.Exit(obj);
}
The lockTaken flag is the detail that makes it correct: Monitor.Enter sets it to true only after the lock is genuinely taken, so if an asynchronous exception strikes between entering the method and acquiring, the finally does not call Exit on a lock it never held. That bool lockTaken plus Monitor.Enter(_, ref lockTaken) plus guarded Monitor.Exit is the twenty-year-old fingerprint of lock.
The new lowering with System.Threading.Lock
Now declare the field as the new type and write the identical statement:
private readonly System.Threading.Lock _gate = new();
void Add(int x)
{
lock (_gate) { _items.Add(x); }
}
The keyword, the braces, the body are the same. The generated code is not. The compiler recognizes that _gate is a System.Threading.Lock and lowers to a scope-based pattern instead:
System.Threading.Lock.Scope scope = _gate.EnterScope();
try
{
_items.Add(x);
}
finally
{
scope.Dispose();
}
EnterScope() acquires the lock and returns a Lock.Scope — a ref struct whose only job is to release the lock when it is disposed. There is no lockTaken flag because EnterScope does not return until the lock is held, so entering the try already implies ownership; the finally disposes the scope unconditionally. In IL the shape is a call to EnterScope, a Lock/Scope local, and a constrained call to Scope.Dispose() in the finally handler:
call valuetype [System.Runtime]System.Threading.Lock/Scope
[System.Runtime]System.Threading.Lock::EnterScope()
stloc.0
.try
{
// body
leave.s END
}
finally
{
ldloca.s 0
call instance void [System.Runtime]System.Threading.Lock/Scope::Dispose()
endfinally
}
Note the ldloca.s (load address of the local) and the direct call to Dispose — the Scope is a struct, so it is disposed in place with no boxing and no null check, the same tell you see when a struct enumerator backs a foreach. The whole thing is, in effect, a specialized using the compiler writes around your body.
How the decompiler tells them apart
Both lowerings are a try/finally around your body, so recognizing lock is pattern-matching on which enter/exit calls bracket it:
- The Monitor shape — a
bool lockTaken, aMonitor.Enter(x, ref lockTaken)as the first act of thetry, and anif (lockTaken) Monitor.Exit(x)as the sole content of thefinally. Match that exactly and the decompiler collapses it tolock (x) { body }. - The scope shape — a
Lock.Scopelocal assigned fromLock.EnterScope()immediately before thetry, and afinallywhose sole effect isDispose()on that scope. Match that and it also collapses tolock (gate) { body }.
A decompiler careful about this — like the ICSharpCode.Decompiler engine under Glass.NET — reconstructs both as the lock statement you wrote, so the folded C# looks the same regardless of which lock type you used. That is correct and readable, but it also means the folded view deliberately hides the one difference that can matter. To see which lowering actually shipped, you drop to the raw form.
The decompile tell that catches a real bug
Here is why the raw shape is worth knowing. System.Threading.Lock only takes its fast path when the compiler can see that the target is a Lock. Capture it into an object first and the compiler loses that knowledge, warns (CS9216), and silently reverts to the Monitor lowering — treating your Lock as an ordinary object monitor:
private readonly System.Threading.Lock _gate = new();
void Buggy()
{
object misused = _gate; // CS9216 — Lock used as object
lock (misused) { /* ... */ } // compiles to Monitor.Enter, NOT EnterScope
}
In source this is easy to miss — it is still a Lock field, still a lock statement. In a decompile it is unmissable: you expected EnterScope/Scope.Dispose, and instead you see Monitor.Enter(_, ref lockTaken). That mismatch — a field of type System.Threading.Lock whose lock sites decompile to Monitor — is the signature of the bug. The code still synchronizes (an object monitor on the Lock instance works), but none of the reason you reached for Lock in the first place applies. When you are auditing a concurrency bug in an assembly you do not have source for, that tell is the fastest way to find a Lock that is not behaving like one.
Why it is worth knowing
The folded lock is what you want to read most of the time, and a good decompiler gives it to you for both lowerings. But the IL underneath now carries information the keyword erases: whether you are looking at the classic Monitor path or the new Lock.Scope path, and — crucially — whether a field that was declared as a Lock is actually being used as one. Glass.NET folds both patterns back to lock by default and lets you drop to the raw EnterScope/Monitor form to confirm which one shipped, which is precisely the view you want when the question is not “what does this lock do?” but “is this lock even the lock I think it is?” See how the same drop-to-raw approach reads the using try/finally and foreach’s struct enumerator, two more places where the lowered shape tells you something the keyword hides.
Try Nebula.NET
Harden your .NET code in minutes — start with the free edition.