Ir al contenido
wannacode

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 a Sell() 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:

Salida de cinco ejecuciones (la tercera línea)
Vendidos según el almacén: 107415
Vendidos según el almacén: 104427
Vendidos según el almacén: 108645
Vendidos según el almacén: 107147
Vendidos según el almacén: 130643
El almacén cuenta decenas de miles de ventas de menos. Esos artículos ya no están, pero el almacén cree que los «tiene»: mañana la tienda los venderá a alguien y luego devolverá el dinero.

La idea

left-- son tres pasos

En el código, 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
  1. Quedan 5 artículos. Caja A y caja B venden uno cada una en el mismo momento.
  2. Caja A lee las existencias: 5.
  3. Caja B lee antes de que A haya escrito: también 5.
  4. Caja A resta 1 en su propia variable: 4.
  5. Caja B hace lo mismo a partir de su 5: también 4.
  6. Caja A escribe 4. Una venta, quedan 4: hasta aquí es correcto.
  7. Caja B también escribe 4, encima del 4 de A. Dos ventas, y las existencias son 4, no 3.
  8. 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.
  1. Leer las existencias en una variable propia.
  2. Restar 1 a tu copia.
  3. 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
El compilador convierte 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 lee
int seenB = left; // B lee
left = seenA - 1; // A escribe
int seenC = left; // C lee
left = seenB - 1; // B escribe
left = seenC - 1; // C escribe
Console.WriteLine("Ventas: 3");
Console.WriteLine($"Existencias: {left}");
Respuesta

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);
Salida de cinco ejecuciones (la tercera línea)
Vendidos según el almacén: 2000
Vendidos según el almacén: 2000
Vendidos según el almacén: 2000
Vendidos según el almacén: 2000
Vendidos según el almacén: 2000
Bien cinco veces. Arrancar un hilo tarda más que 1000 ventas: la primera caja termina antes de que la segunda haya empezado. La condición de carrera está ahí, solo que no ocurre.

Eso 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);
Salida de cinco ejecuciones
Vendidos: 2, existencias después: -1
Vendidos: 2, existencias después: -1
Vendidos: 2, existencias después: -1
Vendidos: 2, existencias después: -1
Vendidos: 2, existencias después: -1

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

La idea

Cuándo puede haber una condición de carrera

Hacen falta tres cosas a la vez:

  1. Datos compartidos: algo que varios hilos pueden ver (las existencias, el contador, el número).
  2. Al menos un hilo los cambia. Si todos solo leen, no hay nada que pueda salir mal.
  3. 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.

Se espera
Caja 1: 50000 ventas
Caja 2: 50000 ventas
Ingresos (€):
300000

Las 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

1. Dos hilos llaman cada uno una vez a left-- sobre un campo compartido que vale 5. ¿Qué puede valer al final?
2. La prueba con dos hilos de 1000 ventas cada uno pasa 100 veces de 100. ¿Qué te dice eso?
3. Queda 1 artículo. El código comprueba «left > 0», cobra la tarjeta durante 100 ms y solo entonces resta 1. Dos personas compran a la vez. ¿Qué pasa?
4. ¿Cuál de estas situaciones NO puede tener una condición de carrera?

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