Decompilar el nuevo lock: cuando el compilador deja de llamar a Monitor
Durante veinte años, lock (obj) se reducía a Monitor.Enter/Exit con un indicador lockTaken. C# 13 lo cambió: un lock sobre un System.Threading.Lock se reduce a EnterScope() y a un ref struct Scope que se libera en un finally, una forma de IL completamente distinta. Aquí explicamos exactamente qué genera cada forma, cómo un decompilador las distingue y pliega ambas de vuelta a lock, y la única pista en la decompilación que delata un error silencioso: un Lock convertido a object que recae discretamente en Monitor.
Durante dos décadas, lock (obj) significó exactamente una cosa en IL: Monitor.Enter a la entrada, un Monitor.Exit protegido en un finally a la salida, y un indicador lockTaken enhebrado entre ambos para que el par adquirir/liberar fuera seguro frente a excepciones. Si has decompilado cualquier código C# concurrente, esa forma la tienes grabada en la memoria. C# 13 y .NET 9 añadieron System.Threading.Lock, un tipo de bloqueo dedicado con su propia ruta rápida, y bloquear sobre él se reduce a algo que no se parece en nada a Monitor. La misma palabra clave, código generado completamente distinto.
Esto importa cuando lees un ensamblado decompilado, porque el IL ahora te dice qué tipo de lock estás mirando, y una de las posibilidades es un error latente que el código fuente te ocultaba. Recorramos ambas reducciones y veamos cómo un decompilador las separa.
La reducción clásica, como referencia
Bloquea sobre un object normal y nada ha cambiado:
private readonly object _gate = new object();
void Add(int x)
{
lock (_gate) { _items.Add(x); }
}
El compilador reduce esto al conocido patrón Monitor. Decompilado sin reconocimiento de lock, se lee así:
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);
}
El indicador lockTaken es el detalle que lo hace correcto: Monitor.Enter lo pone a true solo después de que el bloqueo se haya adquirido de verdad, así que si una excepción asíncrona golpea entre la entrada al método y la adquisición, el finally no llama a Exit sobre un bloqueo que nunca tuvo. Ese bool lockTaken más Monitor.Enter(_, ref lockTaken) más el Monitor.Exit protegido es la huella de lock desde hace veinte años.
La nueva reducción con System.Threading.Lock
Ahora declara el campo con el nuevo tipo y escribe la instrucción idéntica:
private readonly System.Threading.Lock _gate = new();
void Add(int x)
{
lock (_gate) { _items.Add(x); }
}
La palabra clave, las llaves y el cuerpo son los mismos. El código generado no. El compilador reconoce que _gate es un System.Threading.Lock y lo reduce en su lugar a un patrón basado en ámbito:
System.Threading.Lock.Scope scope = _gate.EnterScope();
try
{
_items.Add(x);
}
finally
{
scope.Dispose();
}
EnterScope() adquiere el bloqueo y devuelve un Lock.Scope: un ref struct cuyo único trabajo es liberar el bloqueo cuando se desecha. No hay indicador lockTaken porque EnterScope no regresa hasta que el bloqueo está adquirido, así que entrar en el try ya implica la propiedad; el finally desecha el ámbito incondicionalmente. En IL la forma es una call a EnterScope, una variable local Lock/Scope y una llamada constrained a Scope.Dispose() en el controlador 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
}
Fíjate en el ldloca.s (cargar la dirección de la variable local) y en la call directa a Dispose: el Scope es un struct, así que se desecha in situ sin boxing y sin comprobación de null, la misma pista que ves cuando un enumerador struct respalda un foreach. En la práctica, todo esto es un using especializado que el compilador escribe alrededor de tu cuerpo.
Cómo las distingue el decompilador
Ambas reducciones son un try/finally alrededor de tu cuerpo, así que reconocer lock consiste en hacer coincidir el patrón de qué llamadas de entrada/salida lo encierran:
- La forma Monitor: un
bool lockTaken, unMonitor.Enter(x, ref lockTaken)como primer acto deltry, y unif (lockTaken) Monitor.Exit(x)como único contenido delfinally. Haz coincidir eso exactamente y el decompilador lo colapsa alock (x) { body }. - La forma de ámbito: una variable local
Lock.Scopeasignada desdeLock.EnterScope()inmediatamente antes deltry, y unfinallycuyo único efecto esDispose()sobre ese ámbito. Haz coincidir eso y también se colapsa alock (gate) { body }.
Un decompilador cuidadoso con esto (como el motor ICSharpCode.Decompiler que hay bajo Glass.NET) reconstruye ambas como la instrucción lock que escribiste, así que el C# plegado se ve igual independientemente del tipo de bloqueo que usaras. Eso es correcto y legible, pero también significa que la vista plegada oculta deliberadamente la única diferencia que puede importar. Para ver qué reducción se publicó realmente, bajas a la forma en bruto.
La pista de la decompilación que atrapa un error real
He aquí por qué merece la pena conocer la forma en bruto. System.Threading.Lock solo toma su ruta rápida cuando el compilador puede ver que el destino es un Lock. Captúralo primero en un object y el compilador pierde ese conocimiento, avisa (CS9216) y recae silenciosamente en la reducción con Monitor, tratando tu Lock como un monitor de objeto ordinario:
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
}
En el código fuente esto es fácil de pasar por alto: sigue siendo un campo Lock, sigue siendo una instrucción lock. En una decompilación es imposible no verlo: esperabas EnterScope/Scope.Dispose y en su lugar ves Monitor.Enter(_, ref lockTaken). Esa discrepancia (un campo de tipo System.Threading.Lock cuyos puntos de bloqueo se decompilan a Monitor) es la firma del error. El código sigue sincronizando (un monitor de objeto sobre la instancia Lock funciona), pero ninguna de las razones por las que elegiste Lock en primer lugar se aplica. Cuando estás auditando un error de concurrencia en un ensamblado del que no tienes el código fuente, esa pista es la forma más rápida de encontrar un Lock que no se comporta como tal.
Por qué merece la pena saberlo
El lock plegado es lo que quieres leer la mayor parte del tiempo, y un buen decompilador te lo da para ambas reducciones. Pero el IL subyacente ahora lleva información que la palabra clave borra: si estás mirando la ruta clásica de Monitor o la nueva ruta de Lock.Scope y, lo que es crucial, si un campo que fue declarado como Lock se está usando realmente como tal. Glass.NET pliega ambos patrones de vuelta a lock de forma predeterminada y te permite bajar a la forma en bruto EnterScope/Monitor para confirmar cuál se publicó, que es precisamente la vista que quieres cuando la pregunta no es “¿qué hace este lock?” sino “¿es este lock siquiera el lock que creo que es?”. Mira cómo el mismo enfoque de bajar a la forma en bruto lee el try/finally de using y el enumerador struct de foreach, dos lugares más donde la forma reducida te dice algo que la palabra clave oculta.
Prueba Nebula.NET
Endurece tu código .NET en minutos — empieza con la edición gratuita.