Condición de carrera
En resumen: Una condición de carrera es un error en el que el resultado depende de cómo se entrelazan los hilos: left-- son tres pasos (leer, restar, escribir), así que dos hilos pueden perder una venta.
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 hilos. Cada uno vende 100 000 artículos y llama aSell() por cada uno:class Stock{ private int left;
public Stock(int left) { this.left = left; }
public void Sell() { left--; // leer, restar 1, escribir }
public int Left => left;}Stock stock = new Stock(200_000);ThreadStart checkout = () =>{ for (int i = 0; i < 100_000; i++) { stock.Sell(); }};Thread a = new Thread(checkout);Thread b = new Thread(checkout);a.Start();b.Start();a.Join();b.Join();Console.WriteLine($"Pedidos: {2 * 100_000}");Console.WriteLine("Vendidos según el almacén:");Console.WriteLine(200_000 - stock.Left);Los clientes han pagado 200 000 pedidos. Estas son las ventas que cuenta el almacén en cinco ejecuciones:
Vendidos según el almacén: 107415Vendidos según el almacén: 104427Vendidos según el almacén: 108645Vendidos según el almacén: 107147Vendidos según el almacén: 130643La idea
left-- son tres pasos
left-- es una línea. El procesador lo hace en tres pasos, como si los escribieras uno por uno:class Stock{ private int left;
public Stock(int left) { this.left = left; }
public void Sell() { int seen = left; // 1. leer int after = seen - 1; // 2. restar 1 left = after; // 3. escribir }
public int Left => left;}Paso a paso, en palabras
- Quedan 5 artículos. Caja A y caja B venden uno cada una en el mismo momento.
- Caja A lee las existencias: 5.
- Caja B lee antes de que A haya escrito: también 5.
- Caja A resta 1 en su propia variable: 4.
- Caja B hace lo mismo a partir de su 5: también 4.
- Caja A escribe 4. Una venta, quedan 4: hasta aquí es correcto.
- Caja 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
left-- en IL con una lectura (ldfld), una resta (sub) y una escritura (stfld) separadas. Puedes verlas en sharplab.io o con ILSpy. El compilador JIT las convierte después en instrucciones de máquina, pero la lectura y la escritura siguen separadas.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:
int left = 5;int seenA = left; // A leeint seenB = left; // B leeleft = seenA - 1; // A escribeint seenC = left; // C leeleft = seenB - 1; // B escribeleft = seenC - 1; // C escribeConsole.WriteLine("Ventas: 3");Console.WriteLine($"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:
Stock stock = new Stock(2_000);ThreadStart checkout = () =>{ for (int i = 0; i < 1_000; i++) { stock.Sell(); }};Thread a = new Thread(checkout);Thread b = new Thread(checkout);a.Start();b.Start();a.Join();b.Join();Console.WriteLine($"Pedidos: {2 * 1_000}");Console.WriteLine("Vendidos según el almacén:");Console.WriteLine(2_000 - stock.Left);Vendidos según el almacén: 2000Vendidos según el almacén: 2000Vendidos según el almacén: 2000Vendidos 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{ private int left;
public Stock(int left) { this.left = left; }
public bool Buy() { if (left > 0) // comprobar: ¿queda uno? { Thread.Sleep(100); // se cobra la tarjeta, ~100 ms left--; // actuar: tomar el artículo return true; } return false; }
public int Left => left;}Stock stock = new Stock(1);bool[] bought = new bool[2];Thread first = new Thread(() => bought[0] = stock.Buy());Thread second = new Thread(() => bought[1] = stock.Buy());first.Start();second.Start();first.Join();second.Join();int sold = bought.Count(b => b);Console.WriteLine("Existencias antes: 1");Console.WriteLine("Vendidos:");Console.WriteLine(sold);Console.WriteLine("Existencias después:");Console.WriteLine(stock.Left);Vendidos: 2, existencias después: -1Vendidos: 2, existencias después: -1Vendidos: 2, existencias después: -1Vendidos: 2, existencias después: -1Vendidos: 2, existencias después: -1Los dos pasan la comprobación: mientras el primero paga, el segundo ve el mismo 1. Los dos pagaron, y las existencias están en -1. Aquí la condición de carrera ocurre siempre, porque entre la comprobación y la acción pasan unos buenos 100 ms.
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() 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. Sin lock o Volatile.Read el JIT puede leer un campo una vez y no volver a leerlo. Un bucle while (!done) { } puede no terminar nunca. 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 50 000 auriculares cada una a 3 €. Cada caja cuenta sus ventas en su propia variable, y los ingresos son compartidos: un objeto Revenue con un método Add(euros) que hace total += euros. Imprime las ventas de cada caja y los ingresos. Ejecuta el programa varias veces.
Caja 1: 50000 ventasCaja 2: 50000 ventasIngresos (€):300000Las 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 hotel con un método «reservar la habitación 12 si está libre» y ejecuta a dos huéspedes a la vez, con una pausa de 100 ms entre la comprobación y la escritura. ¿Cuántos huéspedes se quedan con la habitación?
Test
Ponte a prueba
Lecciones relacionadas
Sección «Lecciones relacionadas»- Se basa en: Listas · Lambdas y delegados · 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