Ir al contenido
wannacode

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:

static int sold = 0;
static boolean lostSales() throws InterruptedException {
sold = 0;
List<Thread> threads = new ArrayList<>();
for (int i = 0; i < 4; i++) {
Thread t = new Thread(() -> {
for (int k = 0; k < 1_000; k++) {
sold++;
}
});
t.start();
threads.add(t);
}
for (Thread t : threads) {
t.join();
}
return sold != 4_000;
}
lostSales();
System.out.println("Vendidos (esperado 4000):");
System.out.println(sold);
Salida
Vendidos (esperado 4000):
4000

Para 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.

static int sold = 0;
static boolean lostSales() throws InterruptedException {
sold = 0;
List<Thread> threads = new ArrayList<>();
for (int i = 0; i < 4; i++) {
Thread t = new Thread(() -> {
for (int k = 0; k < 1_000; k++) {
sold++;
}
});
t.start();
threads.add(t);
}
for (Thread t : threads) {
t.join();
}
return sold != 4_000;
}
Paso a paso, en palabras
  1. La prueba inicia 4 hilos, cada uno vende 1000 veces sin cerrojo. Sin barrera, cada hilo arranca en cuanto se crea.
  2. 20 ejecuciones: en este ejemplo 0 de 20 en rojo. Un hilo suele terminar sus 1000 ventas antes de que arranque el siguiente.
  3. Una barrera: cada hilo espera en la salida hasta que lleguen los cuatro. Luego corren juntos.
  4. 20 ejecuciones con barrera: 11 de 20 en rojo. El mismo contador roto, pero ahora la prueba lo detecta una y otra vez.

La prueba con una barrera:

static int sold = 0;
static boolean lostSales() throws InterruptedException {
sold = 0;
CyclicBarrier start = new CyclicBarrier(4);
List<Thread> threads = new ArrayList<>();
for (int i = 0; i < 4; i++) {
Thread t = new Thread(() -> {
waitAt(start);
for (int k = 0; k < 1_000; k++) {
sold++;
}
});
t.start();
threads.add(t);
}
for (Thread t : threads) {
t.join();
}
return sold != 4_000;
}
// Espera en la barrera hasta que lleguen todos (como máximo 1 s).
static void waitAt(CyclicBarrier start) {
try {
start.await(1, TimeUnit.SECONDS);
} catch (Exception e) {
throw new IllegalStateException(e);
}
}
int failed = 0;
for (int run = 0; run < 20; run++) {
if (lostSales()) {
failed++;
}
}
System.out.println("Ejecuciones con ventas perdidas (de 20):");
System.out.println(failed);
Salida
Ejecuciones con ventas perdidas (de 20):
20

La salida es «como máximo 20»: cuántas ejecuciones salen en rojo depende de la máquina. En mi máquina sin barrera son 0 de 20, con barrera unas 11.

CyclicBarrier(4): await espera hasta que 4 hilos lo hayan llamado, y entonces los suelta a todos. waitAt espera como máximo 1 segundo: una prueba no debe colgarse si un hilo nunca llega.

La misma prueba contra el contador con cerrojo:

static int sold = 0;
static final Object lock = new Object();
static boolean lostSales() throws InterruptedException {
sold = 0;
CyclicBarrier start = new CyclicBarrier(4);
List<Thread> threads = new ArrayList<>();
for (int i = 0; i < 4; i++) {
Thread t = new Thread(() -> {
waitAt(start);
for (int k = 0; k < 1_000; k++) {
synchronized (lock) {
sold++;
}
}
});
t.start();
threads.add(t);
}
for (Thread t : threads) {
t.join();
}
return sold != 4_000;
}
Salida
Ejecuciones con ventas perdidas: 0 de 20
Má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 C# la barrera es Barrier, en Python threading.Barrier, en Node la escribes con Atomics.wait y Atomics.notify. En lugar de una barrera también es común un latch: un CountDownLatch(1) que el hilo principal abre cuando todos los hilos están iniciados.

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:

static int sold = 0;
static boolean lostSales() throws InterruptedException {
sold = 0;
for (int i = 0; i < 4; i++) {
Thread t = new Thread(() -> {
for (int k = 0; k < 1_000; k++) {
sold++;
}
});
t.start();
t.join();
}
return sold != 4_000;
}
Salida
Ejecuciones con ventas perdidas: 0 de 20

Elige la línea con el error.

Pruébalo

Completa

¿Qué línea hace que los hilos arranquen juntos?

static int sold = 0;

static boolean lostSales() throws InterruptedException {
    sold = 0;
    CyclicBarrier start = new CyclicBarrier(4);
    List<Thread> threads = new ArrayList<>();
    for (int i = 0; i < 4; i++) {
        Thread t = new Thread(() -> {
            
            for (int k = 0; k < 1_000; k++) {
                sold++;
            }
        });
        t.start();
        threads.add(t);
    }
    for (Thread t : threads) {
        t.join();
    }
    return sold != 4_000;
}

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.

  1. El primer comprador lee 10 y se detiene en la costura.
  2. El segundo comprador pasa de punta a punta: lee 10, escribe 9.
  3. El primero continúa y escribe su 10 − 1 = 9.
class Stock {
int count;
Runnable pause = () -> {};
Stock(int count) {
this.count = count;
}
void buy() {
int left = count;
pause.run(); // una costura para la prueba: aquí la prueba puede detener el hilo
count = left - 1;
}
}
static String forcedTest(Stock stock) throws InterruptedException {
CountDownLatch read = new CountDownLatch(1);
CountDownLatch go = new CountDownLatch(1);
stock.pause = () -> {
if (Thread.currentThread().getName().equals("first")) {
read.countDown();
waitFor(go);
}
};
Thread first = new Thread(stock::buy, "first");
Thread second = new Thread(stock::buy, "second");
first.start();
waitFor(read); // first ha leído 10 y espera
second.start();
second.join(200); // second compra hasta el final o espera el cerrojo
go.countDown(); // first escribe
first.join(1000);
second.join(1000);
String verdict = stock.count == 8 ? "PASA" : "FALLA";
return "Existencias: " + stock.count + " (esperado 8): " + verdict;
}
static void waitFor(CountDownLatch latch) {
try {
latch.await(1, TimeUnit.SECONDS);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
Salida
Existencias: 9 (esperado 8): FALLA
La costura es el campo pause. La prueba le pone código que detiene solo al hilo llamado first: abre el latch read y espera el latch go. Un CountDownLatch(1) es un latch: countDown() lo abre, await espera a que se abra. waitFor espera como máximo 1 segundo.

Las existencias son 9 en lugar de 8, en cada ejecución. Ahora la corrección: synchronized en buy:

class Stock {
int count;
Runnable pause = () -> {};
Stock(int count) {
this.count = count;
}
synchronized void buy() {
int left = count;
pause.run(); // una costura para la prueba: aquí la prueba puede detener el hilo
count = left - 1;
}
}
Salida
Existencias: 8 (esperado 8): PASA

La 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?

Stock stock = new Stock(10);
CountDownLatch allRead = new CountDownLatch(3);
stock.pause = () -> {
allRead.countDown();
waitFor(allRead);
};
List<Thread> buyers = new ArrayList<>();
for (int i = 0; i < 3; i++) {
Thread t = new Thread(stock::buy);
t.start();
buyers.add(t);
}
for (Thread t : buyers) {
t.join();
}
System.out.println("Existencias: " + stock.count);
Respuesta

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:

Shop shop = new Shop(100);
int[] bought = new int[8];
CyclicBarrier start = new CyclicBarrier(8);
List<Thread> buyers = new ArrayList<>();
for (int i = 0; i < 8; i++) {
int me = i;
Thread t = new Thread(() -> {
waitAt(start);
for (int k = 0; k < 50; k++) {
if (shop.tryBuy()) {
bought[me]++;
}
}
});
t.start();
buyers.add(t);
}
for (Thread t : buyers) {
t.join();
}
int total = Arrays.stream(bought).sum();
System.out.println("Existencias: " + shop.stock() + ", vendidos: " + shop.sold());
System.out.println(check("existencias + vendidos = 100", shop.stock() + shop.sold() == 100));
System.out.println(check("existencias >= 0", shop.stock() >= 0));
System.out.println(check("comprados = vendidos", total == shop.sold()));
Salida
Existencias: 0, vendidos: 100
existencias + vendidos = 100: PASA
existencias >= 0: PASA
comprados = vendidos: PASA

La tienda:

class Shop {
private int stock;
private int sold = 0;
Shop(int stock) {
this.stock = stock;
}
synchronized boolean tryBuy() {
if (stock == 0) {
return false;
}
stock--;
sold++;
return true;
}
synchronized int stock() {
return stock;
}
synchronized int sold() {
return sold;
}
}
Cada hilo escribe en su propio lugar 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:

static final Object sofia = new Object();
static final Object plovdiv = new Object();
static void transfer(Object from, Object to) {
synchronized (from) {
sleep(50);
synchronized (to) {
// trasladar la mercancía
}
}
}
static void sleep(int ms) {
try {
Thread.sleep(ms);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
static boolean finishesWithin(long ms, Runnable... parts) throws InterruptedException {
List<Thread> threads = new ArrayList<>();
for (Runnable part : parts) {
Thread t = new Thread(part);
t.setDaemon(true);
t.start();
threads.add(t);
}
long end = System.currentTimeMillis() + ms;
for (Thread t : threads) {
t.join(Math.max(1, end - System.currentTimeMillis()));
}
return threads.stream().noneMatch(Thread::isAlive);
}
boolean ok = finishesWithin(1000,
() -> transfer(sofia, plovdiv),
() -> transfer(plovdiv, sofia));
System.out.println("Prueba «traslado entre almacenes»: " + (ok ? "PASA" : "no terminó en 1 s: FALLA"));
Salida
Prueba «traslado entre almacenes»: no terminó en 1 s: FALLA
join(ms) espera como máximo ms. Luego isAlive() dice si el hilo sigue ejecutándose. setDaemon(true) deja que la JVM termine aunque los dos hilos estén bloqueados para siempre.
Más precisamente, para curiosos

Los frameworks de pruebas traen el timeout ya hecho: @Timeout(1) o assertTimeoutPreemptively en JUnit 5. 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.
Salida
Ejecuciones: 20
Con suma incorrecta: 0
Con timeout: 0
La caja: PASA

Má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 sell y 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

1. La prueba del contador sin cerrojo pasa. ¿Qué significa?
2. ¿Para qué sirve la barrera al inicio de la prueba de estrés?
3. La prueba de estrés pasa 20 de 20 veces. ¿Es seguro el código?
4. ¿Cómo haces que una prueba falle siempre con la misma condición de carrera?
5. ¿Por qué una prueba con hilos debe tener un timeout?

Creado por Ivo Gergov como parte de la campaña de Evo Tech por una educación accesible en programación ·