Condición de carrera
En resumen: Una condición de carrera es un error en el que el resultado depende del orden de ejecución: dos Workers pierden una venta, y un await entre la comprobación y la compra deja pasar a dos compradores incluso con un solo hilo.
Problema
La venta se cuenta mal
Viernes, venta relámpago. Los auriculares están a mitad de precio y los pedidos llegan a raudales. Dos cajas venden de un mismo almacén: cada venta resta 1 de las existencias.
Las cajas son dos Workers (la lección «Async, await e hilos»). Las existencias viven en unSharedArrayBuffer, una memoria compartida que ven los dos. Cada uno vende 1 000 000 de artículos:// stock es un Int32Array en un SharedArrayBuffer:// memoria compartida por los Workers.function sell(stock) { stock[0]--; // leer, restar 1, escribir}if (isMainThread) { const stock = new Int32Array(new SharedArrayBuffer(4)); stock[0] = 2_000_000; const checkouts = [1, 2].map( () => new Worker(new URL(import.meta.url), { workerData: stock }), ); await Promise.all(checkouts.map((worker) => once(worker, "exit"))); console.log(`Pedidos: ${2 * 1_000_000}`); console.log("Vendidos según el almacén:"); console.log(2_000_000 - stock[0]);} else { for (let i = 0; i < 1_000_000; i++) { sell(workerData); }}Los clientes han pagado 2 000 000 pedidos. Estas son las ventas que cuenta el almacén en cinco ejecuciones:
Vendidos según el almacén: 2000000Vendidos según el almacén: 1003506Vendidos según el almacén: 994022Vendidos según el almacén: 1004855Vendidos según el almacén: 1018607La idea
stock[0]-- son tres pasos
stock[0]-- es una línea. El procesador lo hace en tres pasos, como si los escribieras uno por uno:// stock es un Int32Array en un SharedArrayBuffer:// memoria compartida por los Workers.function sell(stock) { const seen = stock[0]; // 1. leer const after = seen - 1; // 2. restar 1 stock[0] = after; // 3. escribir}Paso a paso, en palabras
- Quedan 5 artículos. Worker A y Worker B venden uno cada una en el mismo momento.
- Worker A lee las existencias: 5.
- Worker B lee antes de que A haya escrito: también 5.
- Worker A resta 1 en su propia variable: 4.
- Worker B hace lo mismo a partir de su 5: también 4.
- Worker A escribe 4. Una venta, quedan 4: hasta aquí es correcto.
- Worker B también escribe 4, encima del 4 de A. Dos ventas, y las existencias son 4, no 3.
- Se pierde una venta: las dos leyeron el mismo número antes de que alguna escribiera. Eso es una condición de carrera, y el almacén muestra ahora un artículo que ya está vendido.
- Leer las existencias en una variable propia.
- Restar 1 a tu copia.
- Escribir la copia de vuelta.
Dos hilos pueden turnarse entre cualquier par de pasos. Si los dos leen 5 antes de que escriba alguno, los dos escriben 4. Dos ventas, y las existencias bajan en uno.
Cuando el resultado depende de qué hilo hace primero cada paso, eso es una condición de carrera. Los tres pasos «leer, cambiar, escribir» sobre datos compartidos son su forma más común.
Más precisamente, para curiosos
stock[0]-- sobre un Int32Array lee de la memoria compartida, resta en un registro y escribe. Entre medias, el otro Worker, que corre en otro núcleo, puede leer el mismo número. Con un objeto JavaScript normal esto no puede pasar: los objetos no se comparten entre Workers, se copian.Pruébalo
Tres cajas, 5 en existencias
Aquí hay tres cajas, entrelazadas a mano en uno de los órdenes posibles. Cada una lee en su propia variable y luego escribe 1 menos:
let left = 5;const seenA = left; // A leeconst seenB = left; // B leeleft = seenA - 1; // A escribeconst seenC = left; // C leeleft = seenB - 1; // B escribeleft = seenC - 1; // C escribeconsole.log("Ventas: 3");console.log(`Existencias: ${left}`);A y B leen 5. A escribe 4. C lee el 4 que acaba de escribirse. B escribe 4 (su propio 5 menos 1), y C escribe 3. Tres ventas, y las existencias son 3 en vez de 2: solo se perdió la venta de B, porque B leyó antes de que A escribiera.
La idea
«En mi máquina funciona»
El mismo programa, con menos ventas por caja:
if (isMainThread) { const stock = new Int32Array(new SharedArrayBuffer(4)); stock[0] = 2_000; const checkouts = [1, 2].map( () => new Worker(new URL(import.meta.url), { workerData: stock }), ); await Promise.all(checkouts.map((worker) => once(worker, "exit"))); console.log(`Pedidos: ${2 * 1_000}`); console.log("Vendidos según el almacén:"); console.log(2_000 - stock[0]);} else { for (let i = 0; i < 1_000; i++) { sell(workerData); }}Vendidos según el almacén: 2000Vendidos según el almacén: 2000Vendidos según el almacén: 1929Vendidos según el almacén: 2000Vendidos según el almacén: 2000Eso es lo que hace traicionera a una condición de carrera:
- la prueba pasa en el portátil y falla en el servidor con 64 núcleos y miles de clientes;
- el error aparece «a veces» y no se repite cuando lo buscas;
- añade una línea de impresión, el orden de los hilos cambia y el error desaparece.
Una prueba sin pérdida no demuestra nada. La seguridad se demuestra por cómo está escrito el código: qué datos se comparten y quién los protege.
Problema
El último artículo
Queda un artículo. Dos compradores pulsan «Comprar» en el mismo momento. buy comprueba si queda uno, cobra la tarjeta (unos 100 ms) y solo entonces se lleva el artículo:
class Stock { constructor(left) { this.left = left; }
async buy() { if (this.left > 0) { // comprobar: ¿queda uno? await sleep(100); // se cobra la tarjeta, ~100 ms this.left--; // actuar: tomar el artículo return true; } return false; }}const stock = new Stock(1);const bought = await Promise.all([stock.buy(), stock.buy()]);console.log("Existencias antes: 1");console.log("Vendidos:");console.log(bought.filter((ok) => ok).length);console.log("Existencias después:");console.log(stock.left);Existencias antes: 1Vendidos:2Existencias después:-1Aquí hay un solo hilo, y el error es el mismo. await sleep(100) deja correr a la otra llamada mientras la primera espera al banco. Esta llega a la comprobación mientras left todavía vale 1. Con un solo hilo el orden es el mismo cada vez, así que el error también ocurre cada vez. Hay más sobre esto en la lección «async no es un hilo».
Esta es la segunda forma de una condición de carrera: comprobar y luego actuar (check-then-act). La decisión se toma sobre un valor que otro puede cambiar antes de que la decisión se lleve a cabo. Está en todas partes: «¿hay una habitación libre?», «¿está libre el nombre de usuario?», «¿hay dinero en la cuenta?».
Pruébalo
Números de pedido
Cada pedido recibe un número: 1001, 1002, 1003… Las cajas son hilos y llaman a take(next) al mismo tiempo. A veces dos pedidos reciben el mismo número. ¿Después de qué línea tiene que entrar el otro hilo para que eso ocurra?
Elige la línea con el error.
Después de la lectura. Si los dos hilos leen 1001 antes de que alguno escriba 1002, los dos devuelven 1001. Dos pedidos con un número: uno sobrescribirá al otro en la base de datos o llegará al cliente equivocado. Es el mismo «leer, cambiar, escribir».
La idea
Cuándo puede haber una condición de carrera
Hacen falta tres cosas a la vez:
- Datos compartidos: algo que varios hilos pueden ver (las existencias, el contador, el número).
- Al menos un hilo los cambia. Si todos solo leen, no hay nada que pueda salir mal.
- Nada ordena el acceso: los hilos entran en sus pasos cuando les toca el turno.
Quita una de las tres y no hay condición de carrera:
- No compartir. Cada hilo cuenta en su propia variable, y al final se suman los resultados. Así funcionaron las 1000 facturas en «Por qué hilos»: cada parte calcula la suya.
- No cambiar. Un objeto que nadie cambia puede ser leído por tantos hilos como quieras. Esa es la lección «Operaciones atómicas y objetos inmutables».
- Ordenar el acceso. Un solo hilo a la vez en los tres pasos. Eso es el cerrojo, la lección siguiente: «Bloqueo».
Más precisamente, para curiosos
Hay también un problema más silencioso: la visibilidad. En un SharedArrayBuffer, una lectura normal puede no ver a tiempo la escritura de otro Worker. Atomics.load y Atomics.store lo garantizan. El cerrojo resuelve también esto: lo que un hilo escribió antes de soltarlo, el siguiente lo ve después de tomarlo.
Cómo es en otros lenguajes: en Java y C# count++ pierde igual que aquí. En Python pierde con menos frecuencia por el GIL, pero pierde. En JavaScript pierde entre Workers sobre un SharedArrayBuffer, y con un solo hilo una condición de carrera existe solo entre dos await.
Ejercicio
Los ingresos de la venta
Resuelve la tarea en tu editor.
Los ingresos. Dos cajas venden 500 000 auriculares cada una a 3 €. Cada caja cuenta sus ventas en su propia variable, y los ingresos son compartidos: un Int32Array en un SharedArrayBuffer, donde cada Worker hace revenue[0] += 3. Imprime las ventas de cada caja y los ingresos. Ejecuta el programa varias veces.
Caja 1: 500000 ventasCaja 2: 500000 ventasIngresos (€):3000000Las ventas de las cajas salen bien en cada ejecución: cada una cuenta en su propia variable. Los ingresos salen menores y distintos cada vez. La solución está en la lección siguiente.
Más tareas
- Cuánto se pierde. Ejecuta la venta con 2, 4 y 8 cajas. ¿Se pierde más con más cajas?
- Encuentra lo compartido. Toma un programa con hilos que hayas escrito y enumera los datos que toca más de un hilo. ¿Cuáles de ellos cambia algún hilo?
- Habitación libre. Escribe un método
async«reservar la habitación 12 si está libre» conawait sleep(100)entre la comprobación y la escritura, y ejecuta a dos huéspedes conPromise.all. ¿Cuántos huéspedes se quedan con la habitación?
Test
Ponte a prueba
Lecciones relacionadas
Sección «Lecciones relacionadas»- Se basa en: Arrays: push, splice y sort · Funciones flecha · Async, await e hilos · Por qué hilos
- Continúa en: Bloqueo · Operaciones atómicas y objetos inmutables · Deadlock · Productor y consumidor
- Ejercicios sobre el tema: Concurrencia y condiciones de carrera