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 Workers venden 1000 veces cada uno, y al final los vendidos deben ser 4000:
if (isMainThread) { const sold = new Int32Array(new SharedArrayBuffer(4)); const done = []; for (let i = 0; i < 4; i++) { const w = new Worker(new URL(import.meta.url), { workerData: { sold } }); done.push(once(w, "exit")); } await Promise.all(done); console.log("Vendidos (esperado 4000):"); console.log(sold[0]);} else { const { sold } = workerData; for (let k = 0; k < 1_000; k++) { sold[0]++; }}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.
Los Workers arrancan despacio (cada uno es un V8 nuevo), así que el primero suele haber terminado sus 1000 ventas antes de que arranque el siguiente. En una máquina de 4 núcleos con la carga de las rebajas es distinto.
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.
async function lostSales() { const sold = new Int32Array(new SharedArrayBuffer(4)); const done = []; for (let i = 0; i < 4; i++) { const w = new Worker(new URL(import.meta.url), { workerData: { sold }, }); done.push(once(w, "exit")); } await Promise.all(done); return sold[0] !== 4_000;}
function worker() { const { sold } = workerData; for (let k = 0; k < 1_000; k++) { sold[0]++; }}async function lostSales() { const sold = new Int32Array(new SharedArrayBuffer(4)); const done = []; for (let i = 0; i < 4; i++) { const w = new Worker(new URL(import.meta.url), { workerData: { sold }, }); done.push(once(w, "exit")); } await Promise.all(done); return sold[0] !== 4_000;}
function worker() { const { sold } = workerData; for (let k = 0; k < 1_000; k++) { sold[0]++; }}async function lostSales() { const sold = new Int32Array(new SharedArrayBuffer(4)); const gate = new Int32Array(new SharedArrayBuffer(4)); const done = []; for (let i = 0; i < 4; i++) { const w = new Worker(new URL(import.meta.url), { workerData: { sold, gate }, }); done.push(once(w, "exit")); } await Promise.all(done); return sold[0] !== 4_000;}
function worker() { const { sold, gate } = workerData; arrive(gate, 4); for (let k = 0; k < 1_000; k++) { sold[0]++; }}Paso a paso, en palabras
- La prueba inicia 4 Workers, cada uno vende 1000 veces sin cerrojo. Sin barrera, cada Worker arranca en cuanto se crea.
- 20 ejecuciones: en este ejemplo 6 de 20 en rojo. Un Worker suele terminar sus 1000 ventas antes de que arranque el siguiente.
- Una barrera: cada Worker espera en la salida hasta que lleguen los cuatro. Luego corren juntos.
- 20 ejecuciones con barrera: 20 de 20 en rojo. El mismo contador roto, pero ahora la prueba lo detecta todas las veces.
La prueba con una barrera:
// Una barrera para Workers: cada uno espera hasta que lleguen todos (como máximo 1 s).function arrive(gate, parties) { Atomics.add(gate, 0, 1); Atomics.notify(gate, 0); const end = Date.now() + 1000; let n; while ((n = Atomics.load(gate, 0)) < parties && Date.now() < end) { Atomics.wait(gate, 0, n, 50); }}
async function lostSales() { const sold = new Int32Array(new SharedArrayBuffer(4)); const gate = new Int32Array(new SharedArrayBuffer(4)); const done = []; for (let i = 0; i < 4; i++) { const w = new Worker(new URL(import.meta.url), { workerData: { sold, gate }, }); done.push(once(w, "exit")); } await Promise.all(done); return sold[0] !== 4_000;}
function worker() { const { sold, gate } = workerData; arrive(gate, 4); for (let k = 0; k < 1_000; k++) { sold[0]++; }}
if (isMainThread) { let failed = 0; for (let run = 0; run < 20; run++) { if (await lostSales()) { failed++; } } console.log("Ejecuciones con ventas perdidas (de 20):"); console.log(failed);} else { worker();}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 sin barrera son unas 6 de 20, con barrera 20 de 20.
La barrera es un contador en unSharedArrayBuffer: cada Worker lo aumenta con Atomics.add, despierta a los demás con Atomics.notify y espera con Atomics.wait hasta que el contador llega a 4. Atomics.wait está permitido en un Worker; en el hilo principal del navegador no. La espera es de 1 segundo como máximo.La misma prueba contra el contador con cerrojo:
async function lostSales() { const sold = new Int32Array(new SharedArrayBuffer(4)); const gate = new Int32Array(new SharedArrayBuffer(4)); const done = []; for (let i = 0; i < 4; i++) { const w = new Worker(new URL(import.meta.url), { workerData: { sold, gate }, }); done.push(once(w, "exit")); } await Promise.all(done); return sold[0] !== 4_000;}
function worker() { const { sold, gate } = workerData; arrive(gate, 4); for (let k = 0; k < 1_000; k++) { Atomics.add(sold, 0, 1); }}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: Java tiene una barrera ya hecha (CyclicBarrier), C# también (Barrier), y Python igual (threading.Barrier). En Node la escribimos nosotros, porque Atomics es el nivel más bajo.
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:
async function lostSales() { const sold = new Int32Array(new SharedArrayBuffer(4)); for (let i = 0; i < 4; i++) { const w = new Worker(new URL(import.meta.url), { workerData: { sold }, }); await once(w, "exit"); } return sold[0] !== 4_000;}
function worker() { const { sold } = workerData; for (let k = 0; k < 1_000; k++) { sold[0]++; }}Ejecuciones con ventas perdidas: 0 de 20Elige la línea con el error.
await once(w, "exit") está en el mismo bucle que crea el Worker. Cada uno termina antes de que se cree el siguiente: los cuatro corren uno tras otro y nunca se solapan. Inicia primero todos, luego espéralos con Promise.all.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?
async function lostSales() {
const sold = new Int32Array(new SharedArrayBuffer(4));
const gate = new Int32Array(new SharedArrayBuffer(4));
const done = [];
for (let i = 0; i < 4; i++) {
const w = new Worker(new URL(import.meta.url), {
workerData: { sold, gate },
});
done.push(once(w, "exit"));
}
await Promise.all(done);
return sold[0] !== 4_000;
}
function worker() {
const { sold, gate } = workerData;
for (let k = 0; k < 1_000; k++) {
sold[0]++;
}
}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.
class Stock { constructor(count) { this.count = count; this.pause = async () => {}; }
async buy(who) { const left = this.count; await this.pause(who); // una costura para la prueba: aquí la prueba puede detener el hilo this.count = left - 1; }}
async function forcedTest(stock) { const read = latch(); const go = latch(); stock.pause = async (who) => { if (who === "first") { read.open(); await go.wait(1000); } }; const first = stock.buy("first"); await read.wait(1000); // first ha leído 10 y espera const second = stock.buy("second"); await Promise.race([second, sleep(200)]); // second compra hasta el final o espera el cerrojo go.open(); // first escribe await Promise.all([first, second]); const verdict = stock.count === 8 ? "PASA" : "FALLA"; return `Existencias: ${stock.count} (esperado 8): ${verdict}`;}Existencias: 9 (esperado 8): FALLAawait (la lección async no es un hilo), así que la costura es await this.pause(who). El latch es una promesa: open() la cumple, y wait(ms) la espera o espera al temporizador, lo que ocurra primero.Las existencias son 9 en lugar de 8, en cada ejecución. Ahora la corrección: la lectura y la escritura sin ningún await en medio:
class Stock { constructor(count) { this.count = count; this.pause = async () => {}; }
async buy(who) { this.count--; // sin await entre la lectura y la escritura await this.pause(who); // una costura para la prueba: aquí la prueba puede detener el hilo }}Existencias: 8 (esperado 8): PASALa prueba es la misma. El primer comprador ya redujo las existencias antes de detenerse en la costura, así que el segundo ve 9. 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?
const stock = new Stock(10);const allRead = countdown(3);stock.pause = async () => { allRead.arrive(); await allRead.done;};await Promise.all([stock.buy(), stock.buy(), stock.buy()]);console.log(`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. countdown(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:
if (isMainThread) { const shop = new Int32Array(new SharedArrayBuffer(8)); const bought = new Int32Array(new SharedArrayBuffer(4 * 8)); const gate = new Int32Array(new SharedArrayBuffer(4)); shop[0] = 100; const done = []; for (let me = 0; me < 8; me++) { const w = new Worker(new URL(import.meta.url), { workerData: { shop, bought, gate, me }, }); done.push(once(w, "exit")); } await Promise.all(done); const total = bought.reduce((a, b) => a + b, 0); console.log(`Existencias: ${shop[0]}, vendidos: ${shop[1]}`); console.log(check("existencias + vendidos = 100", shop[0] + shop[1] === 100)); console.log(check("existencias >= 0", shop[0] >= 0)); console.log(check("comprados = vendidos", total === shop[1]));} else { const { shop, bought, gate, me } = workerData; arrive(gate, 8); for (let k = 0; k < 50; k++) { if (tryBuy(shop)) { bought[me]++; } }}Existencias: 0, vendidos: 100existencias + vendidos = 100: PASAexistencias >= 0: PASAcomprados = vendidos: PASALa tienda:
// shop[0] son las existencias, shop[1] es el número de vendidos.function tryBuy(shop) { while (true) { const left = Atomics.load(shop, 0); if (left === 0) return false; if (Atomics.compareExchange(shop, 0, left, left - 1) === left) { Atomics.add(shop, 1, 1); return true; } }}tryBuy no tiene cerrojo: Atomics.compareExchange reduce las existencias solo si siguen siendo el valor que leímos (la lección Operaciones atómicas y objetos inmutables). Cada Worker escribe en su propio lugar de bought, así que allí no hace falta nada.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:
// Un cerrojo en un SharedArrayBuffer: 0 está libre, 1 está ocupado.function lock(locks, i) { while (Atomics.compareExchange(locks, i, 0, 1) !== 0) { Atomics.wait(locks, i, 1, 50); }}
function transfer({ locks, from, to }) { lock(locks, from); Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 50); // una pausa de 50 ms lock(locks, to); // trasladar la mercancía}
async function finishesWithin(ms, jobs) { const workers = jobs.map((job) => new Worker(new URL(import.meta.url), { workerData: job })); const done = Promise.all(workers.map((w) => once(w, "exit"))).then(() => true); const ok = await Promise.race([done, sleep(ms).then(() => false)]); await Promise.all(workers.map((w) => w.terminate())); return ok;}
if (isMainThread) { const locks = new Int32Array(new SharedArrayBuffer(8)); const ok = await finishesWithin(1000, [ { locks, from: 0, to: 1 }, { locks, from: 1, to: 0 }, ]); console.log("Prueba «traslado entre almacenes»: " + (ok ? "PASA" : "no terminó en 1 s: FALLA"));} else { transfer(workerData);}Prueba «traslado entre almacenes»: no terminó en 1 s: FALLASharedArrayBuffer: Atomics.compareExchange toma el cerrojo si está libre, y Atomics.wait espera. Promise.race espera a los dos Workers o al temporizador. Luego terminate() detiene a ambos; si no, el programa nunca terminaría.Más precisamente, para curiosos
Los frameworks de pruebas traen el timeout ya hecho: { timeout: 1000 } en node:test. 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 Atomics.add(register, 0, euros). Escribe una prueba de estrés:
- 20 ejecuciones. En cada una, 4 Workers con una barrera al inicio; cada uno vende 500 veces por 2 €. La suma esperada es 4000 €.
- Una ejecución en la que los Workers 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 (
register[0] += euros) 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