Cuando varios hilos leen y escriben los mismos datos a la vez, las actualizaciones pueden perderse o verse a medio hacer. La sentencia lock hace que un bloque de código sea mutuamente excluyente: mientras un hilo está dentro, cualquier otro hilo que llegue a un lock sobre el mismo objeto espera su turno.
El problema: una condición de carrera
Cuatro tareas suman cada una 100.000 a un contador compartido. El resultado debería ser 400.000:
Salida de ejemplo:
Expected 400000, got 245609
El total suele quedarse corto, y por una cantidad distinta en cada ejecución (en una máquina de un solo núcleo puede salir bien de vez en cuando, lo que hace que estos bugs sean difíciles de detectar). count++ parece una sola operación, pero son tres: leer count, sumar uno y volver a escribirlo. Dos hilos pueden leer los dos 500, calcular los dos 501 y escribir los dos 501, y se pierde un incremento.
La solución: lock
Envuelve la lectura, modificación y escritura en un lock sobre un objeto compartido:
Salida:
Expected 400000, got 400000
Ahora solo un hilo a la vez puede estar dentro del bloque, así que cada incremento termina antes de que el siguiente lea el valor. El lock también garantiza la visibilidad: un hilo que entra en el lock ve todas las escrituras que hizo el anterior poseedor antes de salir.
La protección solo funciona si todos los accesos a count pasan por el mismo lock. Un solo count++ sin lock en otra parte del programa devuelve la condición de carrera.
En qué se compila lock
lock es una forma abreviada de la clase Monitor, con un try/finally para que el lock se libere aunque el bloque lance una excepción:
lock (sync)
{
count++;
}
// is compiled roughly as:
bool taken = false;
try
{
Monitor.Enter(sync, ref taken);
count++;
}
finally
{
if (taken) Monitor.Exit(sync);
}
Monitor también ofrece TryEnter(obj, timeout), que se rinde tras un tiempo límite en lugar de esperar para siempre, y Wait/Pulse para enviar señales entre hilos. Un hilo que ya tiene un lock puede volver a entrar en él (los locks son reentrantes), así que un método con lock puede llamar a otro método con lock sobre el mismo objeto.
Elegir el objeto de bloqueo
El objeto de lock (...) es solo una ficha en la que se ponen de acuerdo los hilos. Las reglas:
- Usa un campo privado de solo lectura de tipo
object.private readonly object sync = new object();. Privado significa que ningún código externo puede tomar el mismo lock;readonlysignifica que la ficha no puede cambiarse mientras un hilo la tiene. - Nunca hagas
lock (this). Cualquiera que tenga una referencia a tu objeto también puede hacer lock sobre él, y entonces su código bloquea el tuyo o provoca un interbloqueo con él. - Nunca hagas lock sobre un string ni sobre
typeof(...). Los literales string están internados (todos los"orders"del proceso son el mismo objeto), y los objetosTypese comparten en toda la aplicación, así que código sin relación puede acabar compitiendo por el mismo lock. - Nunca hagas lock sobre un tipo de valor.
lock (count)sobre unintno compila (error CS0185), y hacerle boxing a mano crea un objeto nuevo cada vez, así que no bloquearía nada. - Usa un lock por cada conjunto de datos que deba mantenerse coherente: un campo de lock static para los datos static y un campo de instancia para los datos de cada instancia.
Proteger operaciones de varios pasos
lock hace más falta allí donde una comprobación y una acción deben ocurrir juntas. Una cuenta segura para hilos muestra tanto el patrón de comprobar y actuar como una instantánea coherente de dos campos:
Salida:
10 left after 33 withdrawals
Sin el lock, dos hilos podrían ver los dos un saldo de 40, pasar los dos la comprobación y retirar los dos 30, dejando la cuenta en menos 20. El método Summary también hace lock, así que nunca informa de un balance posterior a una retirada junto con un recuento de withdrawals anterior a ella.
Dictionary<TKey, TValue> y List<T> tampoco son seguros para hilos: las escrituras concurrentes pueden corromper sus arrays internos, no solo perder actualizaciones. Protégelos con un lock o usa ConcurrentDictionary<TKey, TValue> y los demás tipos de System.Collections.Concurrent.
Interlocked para valores sueltos
Cuando el estado compartido es un entero o una referencia y la actualización es de un solo paso, la clase Interlocked la hace de forma atómica sin lock:
Salida:
Visits: 10000
Bytes: 5120000
Peak: 10000
Increment, Decrement, Add y Exchange corresponden a operaciones atómicas del procesador (una sola instrucción en x64). CompareExchange(ref location, newValue, expected) escribe solo si la posición sigue conteniendo expected, y devuelve lo que encontró, lo que te permite construir cualquier actualización como un bucle de reintento. Todo lo que toque dos variables a la vez sigue necesitando un lock.
Interbloqueos
Un interbloqueo ocurre cuando dos hilos tienen cada uno un lock que necesita el otro:
// Thread 1 // Thread 2
lock (accountA) lock (accountB)
{ {
lock (accountB) { /* ... */ } lock (accountA) { /* ... */ }
} }
Si el hilo 1 toma accountA mientras el hilo 2 toma accountB, cada uno espera después para siempre al otro. No hay excepción ni tiempo límite; el programa se cuelga. Una transferencia entre dos cuentas que hace lock sobre "origen" y después sobre "destino" produce exactamente esto cuando se ejecutan a la vez dos transferencias en sentidos opuestos.
Las defensas habituales:
- Haz los locks en un orden global fijo. En una transferencia, bloquea primero la cuenta con el ID más pequeño, vaya el dinero en el sentido que vaya.
- Mantén los locks poco tiempo y haz el trabajo lento (E/S, logging, llamadas de red) fuera de ellos.
- No llames a código desconocido mientras tienes un lock: los eventos, los callbacks y los métodos virtuales pueden tomar sus propios locks.
- Usa
Monitor.TryEntercon un tiempo límite donde un cuelgue sería peor que un fallo.
Sin await dentro de lock: usa SemaphoreSlim
await no está permitido dentro de un bloque lock (error de compilación CS1996). Después de un await, el método puede continuar en otro hilo, y un lock de Monitor debe liberarlo el mismo hilo que lo tomó. Para código async, SemaphoreSlim con un contador de 1 funciona como un lock compatible con async:
Salida:
5 saved, one at a time
Empareja siempre WaitAsync con Release en un finally, o una excepción dejará la puerta cerrada para siempre. Un SemaphoreSlim(3, 3) deja entrar a tres llamadores a la vez, que es como se limita el número de peticiones concurrentes a una API con límite de uso.
System.Threading.Lock (.NET 9)
.NET 9 con C# 13 añade un tipo específico System.Threading.Lock. Cuando el objeto de una sentencia lock es un Lock, el compilador usa su API EnterScope, más rápida, en lugar de Monitor:
private readonly Lock sync = new Lock();
public void Add(decimal amount)
{
lock (sync) // uses Lock.EnterScope(), not Monitor
{
balance += amount;
}
}
Las reglas para elegir el objeto siguen siendo las mismas. En versiones anteriores, private readonly object es la opción correcta.
Errores comunes
- Proteger con lock algunos accesos pero no todos. Cada lectura y escritura de los datos compartidos debe tomar el mismo lock.
lock (this),lock (typeof(X)),lock ("name"). El código externo puede tomar el mismo lock.- Hacer E/S lenta dentro de un lock. Todos los demás hilos esperan; mantén el bloque corto.
- Tomar dos locks en órdenes distintos en sitios distintos. El interbloqueo clásico.
- Usar
lockalrededor deawait. No compila; usaSemaphoreSlim. - Un lock por llamada.
lock (new object())no protege nada; el objeto debe ser compartido.
Preguntas frecuentes
¿Qué hace lock en C#?
lock (obj) { ... } permite que solo un hilo a la vez ejecute el bloque para un objeto de bloqueo dado. Un segundo hilo que llega a un lock sobre el mismo objeto espera hasta que el primero sale del bloque. El lock se libera aunque el bloque lance una excepción, porque lock se compila como Monitor.Enter y Monitor.Exit dentro de un try/finally.
¿Sobre qué objeto debo hacer lock en C#?
Sobre un campo privado dedicado: private readonly object _sync = new object();. Debe ser un tipo de referencia, compartido por todos los hilos que tocan los datos protegidos, y no accesible desde fuera de tu clase. Nunca hagas lock sobre this, un Type (typeof(MyClass)) o un string, porque otro código puede hacer lock sobre el mismo objeto y bloquearte o provocar un interbloqueo.
¿Cuándo debo usar Interlocked en lugar de lock?
Cuando el estado compartido es un solo número o una sola referencia y la operación es de un solo paso: Interlocked.Increment(ref count), Interlocked.Add(ref total, x), Interlocked.Exchange o CompareExchange. Son operaciones atómicas del hardware y son más rápidas que un lock. Para todo lo que toque varios campos o una colección, usa lock.
¿Puedo usar await dentro de un lock en C#?
No, el compilador rechaza await dentro de un bloque lock (error CS1996), porque el código posterior al await puede reanudarse en un hilo distinto del que tiene el lock. Usa en su lugar SemaphoreSlim con un contador de 1: await sem.WaitAsync(); try { ... } finally { sem.Release(); }.
¿Cómo se producen los interbloqueos con lock?
El hilo 1 tiene el lock A y espera el lock B, mientras el hilo 2 tiene el lock B y espera el lock A. Ninguno puede avanzar, y el programa se cuelga sin ninguna excepción. Se evita tomando siempre varios locks en el mismo orden global, manteniendo los locks el menor tiempo posible y no llamando nunca a código desconocido mientras se tiene un lock.