Cómo probar código concurrente
En resumen: El código concurrente se prueba con una prueba de estrés de muchas ejecuciones, una barrera de inicio, entrelazados forzados y la comprobación de un invariante, porque una ejecución en verde no demuestra nada.
Problema
En mi máquina pasa
El contador de ventas de la lección Condición de carrera no tiene cerrojo. Un compañero escribe una prueba: cuatro hilos venden 1000 veces cada uno, y al final los vendidos deben ser 4000:
int sold = 0;
bool LostSales(){ sold = 0; var threads = new List<Thread>(); for (int i = 0; i < 4; i++) { var t = new Thread(() => { for (int k = 0; k < 1_000; k++) { sold++; } }); t.Start(); threads.Add(t); } foreach (var t in threads) { t.Join(); } return sold != 4_000;}
LostSales();Console.WriteLine("Vendidos (esperado 4000):");Console.WriteLine(sold);Vendidos (esperado 4000):4000Para ellos la prueba pasa. En la CI también pasa. El código entra en las rebajas, y en la primera hora la tienda ha vendido más artículos de los que muestra el contador.
Crear un hilo tarda más que 1000 incrementos. El primer hilo suele haber terminado antes de que arranque el último, y los hilos no se solapan en absoluto.
Una prueba en verde de código concurrente no demuestra casi nada: en esta ejecución los hilos simplemente no se encontraron justo entre la lectura y la escritura. ¿Cómo escribes una prueba que atrape un error así?
La idea
Una prueba de estrés: muchas ejecuciones y una barrera
Tres cosas hacen visible el error:
- Muchas ejecuciones. La prueba no es una ejecución sino 20 (o 1000 en un trabajo nocturno de CI). Cuentas cuántas salen en rojo.
- Muchos hilos a la vez. Más hilos que núcleos, para que se alternen a mitad del trabajo.
- Una barrera al inicio. Cada hilo espera hasta que lleguen todos, y arrancan juntos. Así se solapan todo el tiempo, no solo al final.
int sold = 0;
bool LostSales(){ sold = 0; var threads = new List<Thread>(); for (int i = 0; i < 4; i++) { var t = new Thread(() => { for (int k = 0; k < 1_000; k++) { sold++; } }); t.Start(); threads.Add(t); } foreach (var t in threads) { t.Join(); } return sold != 4_000;}int sold = 0;
bool LostSales(){ sold = 0; var threads = new List<Thread>(); for (int i = 0; i < 4; i++) { var t = new Thread(() => { for (int k = 0; k < 1_000; k++) { sold++; } }); t.Start(); threads.Add(t); } foreach (var t in threads) { t.Join(); } return sold != 4_000;}int sold = 0;
bool LostSales(){ sold = 0; var start = new Barrier(4); var threads = new List<Thread>(); for (int i = 0; i < 4; i++) { var t = new Thread(() => { start.SignalAndWait(TimeSpan.FromSeconds(1)); for (int k = 0; k < 1_000; k++) { sold++; } }); t.Start(); threads.Add(t); } foreach (var t in threads) { t.Join(); } return sold != 4_000;}Paso a paso, en palabras
- La prueba inicia 4 hilos, cada uno vende 1000 veces sin cerrojo. Sin barrera, cada hilo arranca en cuanto se crea.
- 20 ejecuciones: en este ejemplo 0 de 20 en rojo. Un hilo suele terminar sus 1000 ventas antes de que arranque el siguiente.
- Una barrera: cada hilo espera en la salida hasta que lleguen los cuatro. Luego corren juntos.
- 20 ejecuciones con barrera: 13 de 20 en rojo. El mismo contador roto, pero ahora la prueba lo detecta una y otra vez.
La prueba con una barrera:
int sold = 0;
bool LostSales(){ sold = 0; var start = new Barrier(4); var threads = new List<Thread>(); for (int i = 0; i < 4; i++) { var t = new Thread(() => { start.SignalAndWait(TimeSpan.FromSeconds(1)); for (int k = 0; k < 1_000; k++) { sold++; } }); t.Start(); threads.Add(t); } foreach (var t in threads) { t.Join(); } return sold != 4_000;}
int failed = 0;for (int run = 0; run < 20; run++){ if (LostSales()) { failed++; }}Console.WriteLine("Ejecuciones con ventas perdidas (de 20):");Console.WriteLine(failed);Ejecuciones con ventas perdidas (de 20):20La salida es «como máximo 20»: cuántas ejecuciones salen en rojo depende de la máquina. En mi máquina (en el contenedor donde se comprueban los programas) sin barrera son 0 de 20, con barrera unas 13.
Barrier(4): SignalAndWait espera hasta que 4 hilos lo hayan llamado, y entonces los suelta a todos. El timeout de 1 segundo evita que la prueba se cuelgue si un hilo nunca llega.La misma prueba contra el contador con cerrojo:
int sold = 0;object sync = new object();
bool LostSales(){ sold = 0; var start = new Barrier(4); var threads = new List<Thread>(); for (int i = 0; i < 4; i++) { var t = new Thread(() => { start.SignalAndWait(TimeSpan.FromSeconds(1)); for (int k = 0; k < 1_000; k++) { lock (sync) { sold++; } } }); t.Start(); threads.Add(t); } foreach (var t in threads) { t.Join(); } return sold != 4_000;}Ejecuciones con ventas perdidas: 0 de 20Más precisamente, para curiosos
Una prueba de estrés encuentra errores, pero no demuestra que no los haya. «0 de 20» significa «esta vez no lo vimos». Por eso en la CI estas pruebas se ejecutan miles de veces, en máquinas con muchos núcleos, y la revisión de código pregunta aparte: qué se comparte y qué cerrojo lo protege.
Cómo es en los otros lenguajes: en Java la barrera es CyclicBarrier (o un latch CountDownLatch), en Python threading.Barrier, en Node la escribes con Atomics.wait y Atomics.notify. En lugar de una barrera, el código C# suele usar un latch: un ManualResetEventSlim que abre el hilo principal.
Pruébalo
Encuentra el error
Otro compañero escribió una prueba de estrés sin barrera. Pasa 20 de 20 veces, en cada ejecución, en cada máquina, aunque el contador no tiene cerrojo:
int sold = 0;
bool LostSales(){ sold = 0; for (int i = 0; i < 4; i++) { var t = new Thread(() => { for (int k = 0; k < 1_000; k++) { sold++; } }); t.Start(); t.Join(); } return sold != 4_000;}Ejecuciones con ventas perdidas: 0 de 20Elige la línea con el error.
t.Join() está en el mismo bucle que t.Start(). Cada hilo termina antes de que se cree el siguiente: los cuatro corren uno tras otro y nunca se solapan. Inicia primero todos, luego espéralos en un segundo bucle.Una prueba así es peor que ninguna prueba: dice «seguro» sobre código que no lo es.
Pruébalo
Completa
¿Qué línea hace que los hilos arranquen juntos?
int sold = 0;
bool LostSales()
{
sold = 0;
var start = new Barrier(4);
var threads = new List<Thread>();
for (int i = 0; i < 4; i++)
{
var t = new Thread(() =>
{
for (int k = 0; k < 1_000; k++)
{
sold++;
}
});
t.Start();
threads.Add(t);
}
foreach (var t in threads)
{
t.Join();
}
return sold != 4_000;
}Cada hilo espera en la barrera hasta que lleguen los cuatro. Sin esta línea la barrera se crea, pero nadie espera en ella, y la prueba es como la que no tiene barrera.
La idea
Forzar el entrelazado
Una prueba de estrés encuentra el error a veces. Para una prueba que falle siempre, la prueba elige el orden de los hilos. Para eso el código tiene una costura: un lugar donde la prueba puede detener el hilo. En el programa real la costura no hace nada.
El escenario es el error de la lección sobre condiciones de carrera: quedan 10 artículos, dos compradores compran uno cada uno.
- El primer comprador lee 10 y se detiene en la costura.
- El segundo comprador pasa de punta a punta: lee 10, escribe 9.
- El primero continúa y escribe su 10 − 1 = 9.
string ForcedTest(Stock stock){ var read = new ManualResetEventSlim(); var go = new ManualResetEventSlim(); stock.Pause = () => { if (Thread.CurrentThread.Name == "first") { read.Set(); go.Wait(TimeSpan.FromSeconds(1)); } }; var first = new Thread(stock.Buy) { Name = "first" }; var second = new Thread(stock.Buy) { Name = "second" }; first.Start(); read.Wait(TimeSpan.FromSeconds(1)); // first ha leído 10 y espera second.Start(); second.Join(200); // second compra hasta el final o espera el cerrojo go.Set(); // first escribe first.Join(1000); second.Join(1000); string verdict = stock.Count == 8 ? "PASA" : "FALLA"; return $"Existencias: {stock.Count} (esperado 8): {verdict}";}
class Stock{ public int Count; public Action Pause = () => { };
public Stock(int count) => Count = count;
public void Buy() { int left = Count; Pause(); // una costura para la prueba: aquí la prueba puede detener el hilo Count = left - 1; }}Existencias: 9 (esperado 8): FALLAPause. La prueba le pone código que detiene solo al hilo llamado first: abre read y espera go. Un ManualResetEventSlim es un latch: Set() lo abre, Wait espera a que se abra, aquí como máximo 1 segundo.Las existencias son 9 en lugar de 8, en cada ejecución. Ahora la corrección: un lock alrededor de la lectura y la escritura:
class Stock{ public int Count; public Action Pause = () => { }; private readonly object sync = new object();
public Stock(int count) => Count = count;
public void Buy() { lock (sync) { int left = Count; Pause(); // una costura para la prueba: aquí la prueba puede detener el hilo Count = left - 1; } }}Existencias: 8 (esperado 8): PASALa prueba es la misma. El primer comprador mantiene el cerrojo mientras está detenido en la costura. El segundo espera el cerrojo, así que second.Join(200) se agota antes de que haya comprado. Solo cuando el primero escribe 9 y suelta el cerrojo, el segundo lee 9 y escribe 8. Una prueba que fallaba siempre antes de la corrección y pasa siempre después es justo lo que quieres en la CI.
Pruébalo
Tres en la costura
El mismo Stock sin cerrojo. Ahora la costura espera hasta que los tres compradores hayan leído las existencias:
¿Qué imprimirá el programa?
var stock = new Stock(10);var allRead = new CountdownEvent(3);stock.Pause = () =>{ allRead.Signal(); allRead.Wait(TimeSpan.FromSeconds(1));};var buyers = new List<Thread>();for (int i = 0; i < 3; i++){ var t = new Thread(stock.Buy); t.Start(); buyers.Add(t);}foreach (var t in buyers){ t.Join();}Console.WriteLine($"Existencias: {stock.Count}");Los tres leen 10 y se detienen. Cuando los tres han leído, continúan y cada uno escribe su 10 − 1 = 9. Tres ventas, y las existencias bajaron en uno. CountdownEvent(3) en la costura es una barrera: hace seguro el peor orden.
La idea
Comprueba el invariante, no el orden
Con 8 compradores, quién compró qué depende de la ejecución. Una prueba que espera «el primer comprador compró 13» falla incluso con código correcto. Por eso la prueba comprueba un invariante: algo que debe ser verdad en cualquier orden.
Para la tienda es: las existencias más el número de vendidos es lo que había al principio, las existencias no bajan de cero, y las compras de los compradores sumadas dan el número de vendidos. 8 compradores hacen 50 intentos cada uno por 100 artículos:
var shop = new Shop(100);int[] bought = new int[8];var start = new Barrier(8);var buyers = new List<Thread>();for (int i = 0; i < 8; i++){ int me = i; var t = new Thread(() => { start.SignalAndWait(TimeSpan.FromSeconds(1)); for (int k = 0; k < 50; k++) { if (shop.TryBuy()) { bought[me]++; } } }); t.Start(); buyers.Add(t);}foreach (var t in buyers){ t.Join();}Console.WriteLine($"Existencias: {shop.Stock}, vendidos: {shop.Sold}");Console.WriteLine(Check("existencias + vendidos = 100", shop.Stock + shop.Sold == 100));Console.WriteLine(Check("existencias >= 0", shop.Stock >= 0));Console.WriteLine(Check("comprados = vendidos", bought.Sum() == shop.Sold));Existencias: 0, vendidos: 100existencias + vendidos = 100: PASAexistencias >= 0: PASAcomprados = vendidos: PASALa tienda:
class Shop{ private int stock; private int sold = 0; private readonly object sync = new object();
public Shop(int stock) => this.stock = stock;
public bool TryBuy() { lock (sync) { if (stock == 0) return false; stock--; sold++; return true; } }
public int Stock { get { lock (sync) return stock; } } public int Sold { get { lock (sync) return sold; } }}bought[me], así que el arreglo no necesita cerrojo. El total es correcto en cualquier orden; quién compró cuánto no está fijado.400 intentos por 100 artículos significan que las existencias terminan en 0 en cualquier orden. Eso también es un invariante, por eso la salida es la misma en cada ejecución.
La idea
Un timeout: el deadlock hace fallar la prueba
Dos almacenes se traspasan mercancía entre sí. Cada traslado bloquea el almacén del que saca, luego el que recibe, en orden contrario. Eso es un deadlock: cada hilo sostiene un cerrojo y espera al otro para siempre.
Una prueba sin timeout no falla: se cuelga. La CI espera una hora hasta que la mata, y nadie sabe qué prueba era. Así que una prueba con hilos siempre espera con un timeout:
object sofia = new object();object plovdiv = new object();
void Transfer(object from, object to){ lock (from) { Thread.Sleep(50); lock (to) { // trasladar la mercancía } }}
bool FinishesWithin(int ms, params ThreadStart[] parts){ var threads = parts.Select(part => new Thread(part) { IsBackground = true }).ToList(); threads.ForEach(t => t.Start()); var end = DateTime.Now.AddMilliseconds(ms); foreach (var t in threads) { var left = end - DateTime.Now; t.Join(left > TimeSpan.Zero ? left : TimeSpan.FromMilliseconds(1)); } return threads.All(t => !t.IsAlive);}
bool ok = FinishesWithin(1000, () => Transfer(sofia, plovdiv), () => Transfer(plovdiv, sofia));Console.WriteLine("Prueba «traslado entre almacenes»: " + (ok ? "PASA" : "no terminó en 1 s: FALLA"));Prueba «traslado entre almacenes»: no terminó en 1 s: FALLAJoin(TimeSpan) espera como máximo ese tiempo y devuelve si el hilo terminó. IsBackground = true deja que el programa termine aunque los dos hilos estén bloqueados para siempre.Más precisamente, para curiosos
Los frameworks de pruebas traen el timeout ya hecho: [Fact(Timeout = 1000)] en xUnit (para pruebas async) o [Timeout] en NUnit. Cómo se escribe una prueba en el lenguaje, mira la lección sobre pruebas.
También hay herramientas que buscan estos errores por ti. Aquí solo las nombramos, sin usarlas:
- jcstress (Java): ejecuta pequeños trozos de código millones de veces en cada orden que permite la JVM y cuenta los resultados.
- ThreadSanitizer (C, C++, Go, Rust) y el detector de carreras de Go (
go test -race): vigilan cada lectura y escritura durante la prueba y dicen «estos dos hilos tocan lo mismo sin cerrojo», aunque esta vez el resultado fuera correcto. - Microsoft.Coyote (.NET): ejecuta código asíncrono y multihilo bajo su propio planificador, prueba muchos órdenes y puede reproducir el orden que falló.
- faulthandler (Python): cuando una prueba se atasca, imprime dónde está cada hilo.
Ejercicio
Una prueba de estrés para la caja
Resuélvelo en tu propio editor.
La caja de las rebajas tiene Sell(int euros). Escribe una prueba de estrés:
- 20 ejecuciones. En cada una, 4 hilos con una barrera al inicio; cada uno vende 500 veces por 2 €. La suma esperada es 4000 €.
- Una ejecución en la que los hilos no terminan en 1 segundo cuenta como timeout, y la prueba continúa.
- Al final imprime cuántas ejecuciones tuvieron una suma incorrecta y cuántas un timeout, y si la caja pasa.
Ejecuciones: 20Con suma incorrecta: 0Con timeout: 0La caja: PASAMás tareas
- Rompe la caja. Quita el cerrojo y mira cuántas ejecuciones salen en rojo. Ejecútala varias veces: el número cambia.
- Un entrelazado forzado. Añade una costura en
selly escribe una prueba que falle siempre sin el cerrojo y pase con él. - Un deadlock en la caja. Añade un segundo cerrojo (la caja y el almacén) en orden contrario y comprueba que tu prueba informa de un timeout en lugar de colgarse.
Test
Ponte a prueba
Lecciones relacionadas
Sección «Lecciones relacionadas»- Se basa en: Operaciones atómicas y objetos inmutables · Deadlock · Grupo de hilos y tareas · async no es un hilo
- Ejercicios sobre el tema: Concurrencia y condiciones de carrera