Das neue lock dekompilieren: wenn der Compiler aufhört, Monitor aufzurufen
Zwanzig Jahre lang wurde lock (obj) zu Monitor.Enter/Exit mit einem lockTaken-Flag abgesenkt. C# 13 hat das geändert: lock auf einem System.Threading.Lock wird zu EnterScope() und einem Scope-ref struct abgesenkt, das in einem finally freigegeben wird — eine völlig andere IL-Form. Hier erfahren Sie genau, was jede Form erzeugt, wie ein Decompiler sie auseinanderhält und beide zurück in lock faltet, und das eine Dekompilierungs-Indiz, das einen stillen Fehler entlarvt: ein Lock, das zu object geboxt wurde und stillschweigend auf Monitor zurückfällt.
Zwei Jahrzehnte lang bedeutete lock (obj) in IL genau eines: Monitor.Enter auf dem Weg hinein, ein abgesichertes Monitor.Exit in einem finally auf dem Weg hinaus, mit einem lockTaken-Flag dazwischen, das das Paar aus Erwerb und Freigabe ausnahmesicher macht. Wenn Sie je nebenläufigen C#-Code dekompiliert haben, hat sich diese Form in Ihr Gedächtnis eingebrannt. C# 13 und .NET 9 haben System.Threading.Lock hinzugefügt, einen dedizierten Lock-Typ mit eigenem schnellen Pfad — und das Sperren auf ihm wird zu etwas abgesenkt, das Monitor in nichts ähnelt. Gleiches Schlüsselwort, völlig anderer erzeugter Code.
Das ist wichtig, wenn Sie eine dekompilierte Assembly lesen, denn die IL verrät Ihnen nun, welche Art von Lock Sie vor sich haben, und eine der Möglichkeiten ist ein latenter Fehler, den der Quellcode vor Ihnen verborgen hat. Gehen wir beide Absenkungen durch und sehen wir, wie ein Decompiler sie auseinandernimmt.
Die klassische Absenkung, zur Referenz
Sperren Sie auf einem einfachen object, und nichts hat sich geändert:
private readonly object _gate = new object();
void Add(int x)
{
lock (_gate) { _items.Add(x); }
}
Der Compiler senkt dies zum vertrauten Monitor-Muster ab. Ohne lock-Erkennung dekompiliert, liest es sich so:
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);
}
Das lockTaken-Flag ist das Detail, das es korrekt macht: Monitor.Enter setzt es erst nachdem die Sperre tatsächlich erworben wurde auf true, sodass das finally bei einer asynchronen Ausnahme zwischen dem Betreten der Methode und dem Erwerb nicht Exit auf einer Sperre aufruft, die es nie gehalten hat. Dieses bool lockTaken plus Monitor.Enter(_, ref lockTaken) plus abgesichertes Monitor.Exit ist der zwanzig Jahre alte Fingerabdruck von lock.
Die neue Absenkung mit System.Threading.Lock
Deklarieren Sie das Feld nun als den neuen Typ und schreiben Sie die identische Anweisung:
private readonly System.Threading.Lock _gate = new();
void Add(int x)
{
lock (_gate) { _items.Add(x); }
}
Das Schlüsselwort, die Klammern, der Rumpf sind dieselben. Der erzeugte Code ist es nicht. Der Compiler erkennt, dass _gate ein System.Threading.Lock ist, und senkt stattdessen zu einem scope-basierten Muster ab:
System.Threading.Lock.Scope scope = _gate.EnterScope();
try
{
_items.Add(x);
}
finally
{
scope.Dispose();
}
EnterScope() erwirbt die Sperre und gibt ein Lock.Scope zurück — ein ref struct, dessen einzige Aufgabe es ist, die Sperre bei seiner Freigabe loszulassen. Es gibt kein lockTaken-Flag, weil EnterScope erst zurückkehrt, wenn die Sperre gehalten wird, sodass das Betreten des try bereits den Besitz impliziert; das finally gibt den Scope bedingungslos frei. In IL ist die Form ein call zu EnterScope, eine Lock/Scope-Lokalvariable und ein constrained-Aufruf von Scope.Dispose() im 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
}
Beachten Sie das ldloca.s (lade die Adresse der Lokalvariablen) und den direkten call zu Dispose — der Scope ist ein struct, wird also an Ort und Stelle ohne Boxing und ohne Nullprüfung freigegeben, dasselbe Indiz, das Sie sehen, wenn ein struct-Enumerator hinter einem foreach steht. Das Ganze ist im Grunde ein spezialisiertes using, das der Compiler um Ihren Rumpf schreibt.
Wie der Decompiler sie auseinanderhält
Beide Absenkungen sind ein try/finally um Ihren Rumpf, daher ist das Erkennen von lock ein Musterabgleich darauf, welche Enter-/Exit-Aufrufe ihn einklammern:
- Die Monitor-Form — ein
bool lockTaken, einMonitor.Enter(x, ref lockTaken)als erste Handlung destryund einif (lockTaken) Monitor.Exit(x)als einziger Inhalt desfinally. Stimmt das exakt überein, faltet der Decompiler es zulock (x) { body }zusammen. - Die Scope-Form — eine
Lock.Scope-Lokalvariable, die unmittelbar vor demtryausLock.EnterScope()zugewiesen wird, und einfinally, dessen einzige WirkungDispose()auf diesem Scope ist. Stimmt das überein, wird es ebenfalls zulock (gate) { body }zusammengefaltet.
Ein Decompiler, der hierbei sorgfältig vorgeht — wie die ICSharpCode.Decompiler-Engine unter Glass.NET — rekonstruiert beide als die lock-Anweisung, die Sie geschrieben haben, sodass das gefaltete C# unabhängig vom verwendeten Lock-Typ gleich aussieht. Das ist korrekt und lesbar, bedeutet aber auch, dass die gefaltete Ansicht bewusst den einen Unterschied verbirgt, der wichtig sein kann. Um zu sehen, welche Absenkung tatsächlich ausgeliefert wurde, steigen Sie zur rohen Form hinab.
Das Dekompilierungs-Indiz, das einen echten Fehler erwischt
Hier ist der Grund, warum es sich lohnt, die rohe Form zu kennen. System.Threading.Lock nimmt seinen schnellen Pfad nur, wenn der Compiler sehen kann, dass das Ziel ein Lock ist. Übernehmen Sie es zuerst in ein object, verliert der Compiler dieses Wissen, warnt (CS9216) und fällt stillschweigend auf die Monitor-Absenkung zurück — und behandelt Ihr Lock als gewöhnlichen Objektmonitor:
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
}
Im Quellcode ist das leicht zu übersehen — es ist immer noch ein Lock-Feld, immer noch eine lock-Anweisung. In einer Dekompilierung ist es unübersehbar: Sie haben EnterScope/Scope.Dispose erwartet und sehen stattdessen Monitor.Enter(_, ref lockTaken). Diese Diskrepanz — ein Feld vom Typ System.Threading.Lock, dessen Sperrstellen zu Monitor dekompilieren — ist die Signatur des Fehlers. Der Code synchronisiert weiterhin (ein Objektmonitor auf der Lock-Instanz funktioniert), aber nichts von dem, weshalb Sie überhaupt zu Lock gegriffen haben, trifft zu. Wenn Sie einen Nebenläufigkeitsfehler in einer Assembly untersuchen, für die Sie keinen Quellcode haben, ist dieses Indiz der schnellste Weg, ein Lock zu finden, das sich nicht wie eines verhält.
Warum es sich lohnt, das zu wissen
Das gefaltete lock ist das, was Sie meistens lesen wollen, und ein guter Decompiler liefert es Ihnen für beide Absenkungen. Aber die IL darunter trägt nun Informationen, die das Schlüsselwort auslöscht: ob Sie den klassischen Monitor-Pfad oder den neuen Lock.Scope-Pfad vor sich haben, und — entscheidend — ob ein Feld, das als Lock deklariert wurde, tatsächlich als eines verwendet wird. Glass.NET faltet beide Muster standardmäßig zurück zu lock und lässt Sie zur rohen EnterScope/Monitor-Form hinabsteigen, um zu bestätigen, welche ausgeliefert wurde — genau die Ansicht, die Sie wollen, wenn die Frage nicht lautet „was macht dieses Lock?”, sondern „ist dieses Lock überhaupt das Lock, für das ich es halte?”. Sehen Sie, wie derselbe Ansatz des Hinabsteigens zur rohen Form das using try/finally und den struct-Enumerator von foreach liest, zwei weitere Stellen, an denen die abgesenkte Form Ihnen etwas verrät, das das Schlüsselwort verbirgt.
Nebula.NET testen
Härten Sie Ihren .NET-Code in wenigen Minuten — starten Sie mit der kostenlosen Edition.