1. Introducción
Veamos una situación conocida al trabajar con hilos. Supongamos que tenemos un contador compartido de éxitos en una aplicación muy simple.
int counter = 0;
void IncrementCounter()
{
for (int i = 0; i < 100_000; i++)
{
counter++; // ¡No atómico!
}
}
// Ejecutamos dos hilos:
Thread t1 = new Thread(IncrementCounter);
Thread t2 = new Thread(IncrementCounter);
t1.Start();
t2.Start();
t1.Join();
t2.Join();
Console.WriteLine($"Counter: {counter}");
Ejecuta este código varias veces. ¡Casi nunca verás 200_000! ¿Por qué? Los dos hilos se interfieren continuamente: a veces ambos leen la variable a la vez, la incrementan — y escriben el mismo resultado. Al final, parte de los incrementos "se pierden".
Esto es una condición de carrera, o Race Condition. Sin respetar reglas de "turno", los hilos literalmente pelean por los datos.
Sección crítica: ¿qué es?
Sección crítica — es el fragmento de código que debe ejecutar solo un hilo a la vez. Volviendo a la analogía de la cocina: es como un grifo abierto — si dos intentan lavarse sobre el mismo lavabo, sudor y pasta de dientes por todas partes. Acordemos que al baño se entra de uno en uno.
En nuestro ejemplo la sección crítica es la línea counter++.
2. La palabra clave lock
En C# hay una forma concisa y segura de crear una sección crítica: la palabra clave lock. Oculta la complejidad del primitivo de sincronización y asegura que solo un hilo entre en el bloque protegido a la vez.
Cómo usar lock
Sintaxis:
lock (lockerObject)
{
// Código que solo puede ejecutar un hilo a la vez
}
lockerObject — es cualquier objeto que exista durante toda la vida del programa. Normalmente se hace así:
private static object locker = new object();
Atención: nunca uses para esto strings, números u objetos a los que alguien más pueda acceder por accidente! Solo objetos privados que sabes que no se usan en otro lugar.
Corrijamos nuestro ejemplo
private static object locker = new object();
int counter = 0;
void IncrementCounter()
{
for (int i = 0; i < 100_000; i++)
{
lock (locker)
{
counter++; // ¡Ahora es atómico!
}
}
}
Ahora dos o diez hilos entrarán por turno en ese fragmento de código. El resultado será el perfecto 200_000. ¡Los gatitos contentos!
3. ¿Cómo funciona lock internamente? La clase Monitor
Internamente la palabra clave lock trabaja con la clase System.Threading.Monitor. Es como un secretario que deja entrar solo con un pase especial.
Sintaxis equivalente a lock (pero más "desnuda"):
Monitor.Enter(locker);
try
{
// Sección crítica
}
finally
{
Monitor.Exit(locker);
}
La diferencia clave — tú debes garantizar manualmente que Monitor.Exit se llame. Normalmente se usa try...finally. Si olvidas llamar Exit(), el hilo quedará "dentro" para siempre y los demás hilos esperarán eternamente — la aplicación se colgará como un viejo Windows instalando actualizaciones.
Tabla: lock vs. manual Monitor
| Método | Seguridad frente a errores | Fácil de escribir | Flexibilidad |
|---|---|---|---|
|
Sí | Sí | No |
|
Sólo con try/finally | No | Sí |
En el 99% de los casos usa lock. El Monitor manual se necesita solo si quieres máxima flexibilidad: por ejemplo, si quieres un método de bloqueo con timeout.
4. Argumentos para lock: ¿qué se puede y qué no?
Error muy habitual de los novatos: usar una string u otro objeto "visible" para el lock. Por ejemplo:
lock ("mylock") { /*...*/ } // ¡Muy mal!
El problema es que las strings están interned (únicas para toda la aplicación), es fácil chocar con librerías externas y terminar con una aplicación "muerta". Usa siempre objetos privados:
private readonly object myLock = new object();
lock (myLock)
{
// solo tu código conoce myLock
}
5. lock: ejemplo con salida por consola
Vamos a practicar intensamente. Creamos una miniaplicación donde dos hilos imprimen líneas, pero el acceso a la consola también está sincronizado — para que el texto no se mezcle.
private static object consoleLock = new object();
void PrintMessages(string name)
{
for (int i = 0; i < 5; i++)
{
lock (consoleLock)
{
Console.WriteLine($"{name}: Mensaje {i + 1}");
Thread.Sleep(50); // Simulamos procesamiento
}
}
}
Thread t1 = new Thread(() => PrintMessages("Hilo 1"));
Thread t2 = new Thread(() => PrintMessages("Hilo 2"));
t1.Start();
t2.Start();
t1.Join();
t2.Join();
Resultado: las líneas salen ordenadas una tras otra, sin mezcla. Este enfoque se usa mucho para logging, para no leer "garabatos" en los logs.
6. Matices útiles
Control manual de bloqueo: Monitor avanzado
Cuando el lock estándar no es suficiente (por ejemplo, si quieres intentar entrar en la sección sin esperar eternamente), puedes usar Monitor.TryEnter.
if (Monitor.TryEnter(locker, 100)) // 100 ms de espera
{
try
{
// Sección crítica
}
finally
{
Monitor.Exit(locker);
}
}
else
{
Console.WriteLine("No se pudo obtener el bloqueo en 100 milisegundos");
}
Esto es útil si tu programa no quiere "colgarse" — por ejemplo, puedes mostrar un mensaje al usuario o hacer algo útil mientras el recurso compartido está ocupado.
Visualización: cómo funciona el bloqueo (diagrama)
flowchart LR
A[Hilo 1: quiere entrar en la sección crítica]
B[Hilo 2: quiere entrar en la sección crítica]
C[locker libre]
D[Hilo 1 ejecuta código dentro del lock]
E[Hilo 2 espera]
F[Hilo 1 salió del lock]
G[Hilo 2 obtiene acceso]
A -- Comprueba locker --> C
C -- locker libre --> D
B -- Comprueba locker --> D
D -- lock ocupado --> E
D -- Termina trabajo --> F
F -- Se libera locker --> G
E -- locker ahora libre --> G
Bloqueos y rendimiento
Los bloqueos funcionan simple: sólo un hilo a la vez puede ejecutar el bloque entre llaves. Es excelente para la integridad de datos, pero... cuantos más hilos "hacen cola", más lento va todo. La sincronización no es una panacea: intenta mantener las secciones críticas lo más pequeñas posible.
Tip: si la ejecución de la sección crítica toma fracciones de milisegundo — perfecto. Si hay cálculos largos, E/S, red o archivos — mejor separarlos fuera del bloque lock. Primero lee/calcula, y luego rápidamente actualiza el valor compartido dentro de la protección.
En entrevistas y en la vida real
En cualquier programa serio que use hilos, te preguntarán: "¿Qué hacer si dos hilos acceden a la misma variable?" Muestra código con un bloqueo — y tu CV seguro no desaparecerá en la bandeja negra del automatismo de RR.HH.
En la práctica, especialmente en sistemas de alta carga, se usan mecanismos de sincronización más avanzados — pero lock y Monitor siguen siendo el estándar de oro para casos simples.
7. Particularidades del uso de bloqueos y errores típicos
El error más común — "olvidar" usar el mismo objeto como lock. Por ejemplo:
void Foo() { lock (a) { ... } }
void Bar() { lock (b) { ... } }
Si ambos métodos manejan la misma variable, pero los objetos a y b son distintos, has creado una protección ficticia — ¡los hilos trabajarán simultáneamente con la variable!
Conclusión: usa siempre el mismo objeto para proteger los mismos datos.
Otro caso — usar un bloqueo demasiado "amplio". Por ejemplo, hacer lock (this) dentro de una clase normal, si no estás seguro de que nadie externo use ese objeto para bloquear. También puede provocar deadlocks y otros bugs divertidos pero indeseables.
Y por último: NO bloquees operaciones largas o externas (I/O, red, archivos) dentro de lock. Arriesgas "tapar" el acceso a otros hilos por mucho tiempo, reduciendo el rendimiento. Sección crítica = solo lo que realmente no se puede hacer en paralelo.
GO TO FULL VERSION