Décompiler le nouveau lock : quand le compilateur cesse d'appeler Monitor
Pendant vingt ans, lock (obj) s'est abaissé en Monitor.Enter/Exit avec un indicateur lockTaken. C# 13 a changé cela : un lock sur un System.Threading.Lock s'abaisse en EnterScope() et en un ref struct Scope disposé dans un finally — une forme IL complètement différente. Voici exactement ce que chaque forme génère, comment un décompilateur les distingue et les replie toutes deux en lock, et le seul indice de décompilation qui révèle un bogue silencieux : un Lock boxé en object qui retombe discrètement sur Monitor.
Pendant deux décennies, lock (obj) a signifié exactement une chose en IL : Monitor.Enter à l’entrée, un Monitor.Exit gardé dans un finally à la sortie, avec un indicateur lockTaken tissé entre les deux pour rendre la paire acquisition/libération sûre vis-à-vis des exceptions. Si vous avez déjà décompilé du C# concurrent, cette forme est gravée dans votre mémoire. C# 13 et .NET 9 ont ajouté System.Threading.Lock, un type de verrou dédié doté de son propre chemin rapide — et verrouiller sur celui-ci s’abaisse en quelque chose qui ne ressemble en rien à Monitor. Même mot-clé, code généré complètement différent.
Cela compte lorsque vous lisez un assembly décompilé, parce que l’IL vous dit désormais quel type de verrou vous regardez, et l’une des possibilités est un bogue latent que la source vous a caché. Parcourons les deux abaissements et voyons comment un décompilateur les distingue.
L’abaissement classique, pour référence
Verrouillez sur un simple object et rien n’a changé :
private readonly object _gate = new object();
void Add(int x)
{
lock (_gate) { _items.Add(x); }
}
Le compilateur abaisse cela vers le schéma Monitor familier. Décompilé sans reconnaissance du lock, il se lit ainsi :
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);
}
L’indicateur lockTaken est le détail qui le rend correct : Monitor.Enter ne le passe à true qu’après que le verrou est réellement acquis, de sorte que si une exception asynchrone survient entre l’entrée dans la méthode et l’acquisition, le finally n’appelle pas Exit sur un verrou qu’il n’a jamais détenu. Ce bool lockTaken plus Monitor.Enter(_, ref lockTaken) plus Monitor.Exit gardé constitue l’empreinte vieille de vingt ans de lock.
Le nouvel abaissement avec System.Threading.Lock
Déclarez maintenant le champ avec le nouveau type et écrivez l’instruction identique :
private readonly System.Threading.Lock _gate = new();
void Add(int x)
{
lock (_gate) { _items.Add(x); }
}
Le mot-clé, les accolades, le corps sont les mêmes. Le code généré ne l’est pas. Le compilateur reconnaît que _gate est un System.Threading.Lock et abaisse à la place vers un schéma basé sur une portée :
System.Threading.Lock.Scope scope = _gate.EnterScope();
try
{
_items.Add(x);
}
finally
{
scope.Dispose();
}
EnterScope() acquiert le verrou et renvoie un Lock.Scope — un ref struct dont le seul rôle est de libérer le verrou lorsqu’il est disposé. Il n’y a pas d’indicateur lockTaken parce que EnterScope ne retourne pas tant que le verrou n’est pas détenu, donc entrer dans le try implique déjà la possession ; le finally dispose la portée inconditionnellement. En IL, la forme est un call vers EnterScope, une variable locale Lock/Scope, et un appel constrained vers Scope.Dispose() dans le gestionnaire finally :
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
}
Notez le ldloca.s (chargement de l’adresse de la variable locale) et le call direct vers Dispose — le Scope est une struct, il est donc disposé sur place sans boxing ni vérification de null, le même indice que vous voyez lorsqu’un énumérateur struct sous-tend un foreach. L’ensemble est, en pratique, un using spécialisé que le compilateur écrit autour de votre corps.
Comment le décompilateur les distingue
Les deux abaissements sont un try/finally autour de votre corps, donc reconnaître lock revient à faire correspondre quels appels d’entrée/sortie l’encadrent :
- La forme Monitor — un
bool lockTaken, unMonitor.Enter(x, ref lockTaken)comme premier acte dutry, et unif (lockTaken) Monitor.Exit(x)comme seul contenu dufinally. Si cela correspond exactement, le décompilateur le réduit enlock (x) { body }. - La forme à portée — une variable locale
Lock.Scopeaffectée depuisLock.EnterScope()immédiatement avant letry, et unfinallydont le seul effet estDispose()sur cette portée. Si cela correspond, il se réduit lui aussi enlock (gate) { body }.
Un décompilateur soigneux sur ce point — comme le moteur ICSharpCode.Decompiler sous Glass.NET — reconstruit les deux en l’instruction lock que vous avez écrite, de sorte que le C# replié a la même apparence quel que soit le type de verrou utilisé. C’est correct et lisible, mais cela signifie aussi que la vue repliée masque délibérément la seule différence qui peut compter. Pour voir quel abaissement a réellement été livré, vous passez à la forme brute.
L’indice de décompilation qui attrape un vrai bogue
Voici pourquoi la forme brute vaut la peine d’être connue. System.Threading.Lock n’emprunte son chemin rapide que lorsque le compilateur peut voir que la cible est un Lock. Capturez-le d’abord dans un object et le compilateur perd cette connaissance, avertit (CS9216) et revient silencieusement à l’abaissement Monitor — traitant votre Lock comme un moniteur d’objet ordinaire :
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
}
Dans la source, c’est facile à manquer — c’est toujours un champ Lock, toujours une instruction lock. Dans une décompilation, c’est impossible à manquer : vous attendiez EnterScope/Scope.Dispose, et à la place vous voyez Monitor.Enter(_, ref lockTaken). Cette discordance — un champ de type System.Threading.Lock dont les sites de verrouillage se décompilent en Monitor — est la signature du bogue. Le code se synchronise toujours (un moniteur d’objet sur l’instance Lock fonctionne), mais aucune des raisons pour lesquelles vous aviez choisi Lock ne s’applique plus. Lorsque vous auditez un bogue de concurrence dans un assembly dont vous n’avez pas la source, cet indice est le moyen le plus rapide de trouver un Lock qui ne se comporte pas comme tel.
Pourquoi cela vaut la peine d’être su
Le lock replié est ce que vous voulez lire la plupart du temps, et un bon décompilateur vous le donne pour les deux abaissements. Mais l’IL en dessous porte désormais une information que le mot-clé efface : si vous regardez le chemin Monitor classique ou le nouveau chemin Lock.Scope, et — surtout — si un champ déclaré comme Lock est réellement utilisé comme tel. Glass.NET replie les deux schémas en lock par défaut et vous permet de passer à la forme brute EnterScope/Monitor pour confirmer lequel a été livré, ce qui est précisément la vue que vous voulez lorsque la question n’est pas « que fait ce verrou ? » mais « ce verrou est-il seulement le verrou que je crois ? ». Voyez comment la même approche de passage au brut se lit dans le try/finally du using et l’énumérateur struct de foreach, deux autres endroits où la forme abaissée vous dit quelque chose que le mot-clé cache.
Essayez Nebula.NET
Renforcez votre code .NET en quelques minutes — commencez avec l'édition gratuite.