Dos peticiones llegan al mismo endpoint “retirar 100€” con medio milisegundo de diferencia. La cuenta tiene 150€. Ambas peticiones leen ese saldo, ambas ven fondos suficientes, ambas aprueban, ambas restan 100€. La cuenta acaba en -50€, y ninguna rama del código debería haber permitido eso. Eso es una race condition: el resultado depende de qué operación termina primero, y “primero” nunca fue algo que el código decidiera.

¿Qué causa una race condition?

Hacen falta dos ingredientes a la vez: estado mutable compartido, y más de un camino de ejecución que lo toca sin coordinarse. Los caminos pueden ser threads, tareas async, procesos, o dos servidores accediendo a la misma fila de base de datos. Quita cualquiera de los dos ingredientes y el bug desaparece. Un solo thread no puede competir contra sí mismo, y un estado inmutable no puede corromperse por leerse dos veces.

La forma clásica es check-then-act: lees un valor, decides algo según ese valor, luego actúas sobre esa decisión. Si otro camino ejecuta su propia secuencia leer-decidir-actuar en el hueco entre tu lectura y tu escritura, los dos toman una decisión que era válida en un instante que ya no existe cuando cualquiera de los dos escribe.

Un contador compartido muestra el mecanismo sin nada más de por medio. counter += 1 parece una sola operación. En realidad son tres: leer el valor actual, sumar uno, escribir el resultado. Si dos threads ejecutan esa secuencia sobre un contador que empieza en 0, y los dos leen antes de que cualquiera escriba, los dos calculan 1 y los dos escriben 1. Un incremento se pierde. Sin excepción, sin línea de log.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
import threading

counter = 0

def increment():
    global counter
    for _ in range(100_000):
        counter += 1

threads = [threading.Thread(target=increment) for _ in range(4)]
for t in threads:
    t.start()
for t in threads:
    t.join()

print(counter)  # expected 400000, usually comes out lower

Ejecútalo unas cuantas veces. El faltante no es el mismo cada vez, y esa es la segunda firma de una race condition: no se reproduce de forma fiable, porque depende del scheduling, que cambia de ejecución a ejecución y de máquina a máquina.

¿Cómo arreglas una race condition con un lock?

Haz atómica la secuencia leer-modificar-escribir, de modo que, una vez que un thread la empieza, ningún otro thread pueda entrelazarse. Un lock hace eso dejando que solo un thread lo mantenga a la vez. Cualquier otro thread que llame a acquire() se bloquea hasta que quien lo tiene llama a release().

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
import threading

counter = 0
lock = threading.Lock()

def increment():
    global counter
    for _ in range(100_000):
        with lock:
            counter += 1

threads = [threading.Thread(target=increment) for _ in range(4)]
for t in threads:
    t.start()
for t in threads:
    t.join()

print(counter)  # 400000, every time

with lock: adquiere al entrar y libera al salir, incluso cuando se lanza una excepción dentro del bloque. Nunca terminas con un lock que nadie libera.

El error que mantiene vivo el bug incluso después de añadir un lock: bloquear la escritura pero no la comprobación. Esta función de dosificación parece segura y no lo es:

1
2
3
4
5
6
SAFE_DOSAGE = 50

def increase_dosage(patient, amount):
    if patient.dosage + amount < SAFE_DOSAGE:   # check — not locked
        with patient.lock:
            patient.dosage += amount             # act — locked

Dos threads pueden pasar la comprobación antes de que cualquiera de los dos tome el lock. Los dos suman su cantidad, y el total supera el límite. El lock tiene que envolver la comprobación y la acción como una única sección crítica, no solo la línea que parece peligrosa:

1
2
3
4
def increase_dosage(patient, amount):
    with patient.lock:
        if patient.dosage + amount < SAFE_DOSAGE:
            patient.dosage += amount

Con el GIL de Python, la versión que solo bloquea la escritura a menudo funciona por casualidad. Por eso sobrevive a la code review: pasa todos los tests en tu laptop, y luego falla bajo carga en producción o el día que migras a una build free-threaded.

¿El JavaScript async puede tener race conditions?

Sí. JavaScript ejecuta tu código en un solo thread en el navegador y en el thread principal de Node, pero el fallo vuelve a aparecer en cualquier sitio donde dos await puedan terminar en un orden que no planeaste. Un cuadro de búsqueda es el caso típico. El usuario escribe “gato”, sale una petición, sigue escribiendo “gatogrande”, sale una segunda petición. Si la primera petición es más lenta, su respuesta llega después de la segunda y sustituye los resultados más recientes por los viejos.

1
2
3
4
5
6
7
8
9
let latestId = 0;

async function search(term) {
  const id = ++latestId;
  const res = await fetch(`/api/search?q=${encodeURIComponent(term)}`);
  const data = await res.json();
  if (id !== latestId) return; // a newer search started; drop this response
  renderResults(data);
}

Cada llamada se marca con un ID antes de pasar por ningún await. Cuando la respuesta vuelve, solo se renderiza si todavía es la petición más reciente. AbortController es la otra herramienta habitual: cancela la petición vieja directamente en lugar de dejarla terminar y descartar el resultado, lo que además ahorra el viaje de red.

La versión más sutil no tiene ninguna llamada de red, solo una variable compartida que atraviesa un await:

1
2
3
4
5
6
7
let totalLength = 0;

async function addPage(n) {
  totalLength += await getPageLength(n); // read happens now, write happens later
}

await Promise.all([addPage(1), addPage(2)]);

Las dos llamadas leen totalLength como 0 antes de que cualquiera de los dos await se resuelva. Las dos calculan su nuevo total desde el mismo punto de partida desactualizado, y la segunda escritura borra la primera. La lectura y la escritura quedan en lados opuestos de un punto de suspensión, y cualquier cosa puede ejecutarse durante una suspensión. Si ese scheduling todavía no te resulta obvio, /es/posts/javascript/async-await-y-event-loop-javascript/ explica qué hace el event loop en cada await. La solución: resuelve primero el valor e incorpóralo al estado compartido en un solo paso síncrono (const len = await getPageLength(n); totalLength += len;), o ejecuta las sumas en secuencia en vez de con Promise.all.

¿Cómo previenes las race conditions en una base de datos?

Mueve el contador a una fila y el hueco entre check y act se ensancha, porque ahora abarca un viaje de ida y vuelta a la base de datos. Dos servidores de aplicación, cada uno ejecutando “leer saldo, validar, escribir nuevo saldo” contra la misma fila, pueden leer 150€ los dos y aprobar un retiro de 100€ los dos. Ese es el ejemplo inicial, a nivel de SQL.

Dos soluciones, con costes distintos.

El lock pesimista toma el lock de la fila por adelantado. En PostgreSQL es SELECT ... FOR UPDATE:

1
2
3
4
5
BEGIN;
SELECT balance FROM accounts WHERE id = 42 FOR UPDATE;
-- balance is 150; the row is now locked
UPDATE accounts SET balance = balance - 100 WHERE id = 42;
COMMIT;

Una segunda transacción que intenta un SELECT ... FOR UPDATE sobre la misma fila se bloquea hasta que la primera hace commit o rollback. Correcto, pero cada petición concurrente sobre esa fila ahora espera en cola detrás del lock. Eso cuesta latencia bajo contención, y puede provocar un deadlock si dos transacciones bloquean filas en orden opuesto. Mantén la transacción corta: aquí la fila se encuentra por clave primaria, que es una búsqueda por índice. Filtra por una columna sin índice y la búsqueda se convierte en un escaneo, que alarga cada transacción mientras las demás esperan (los detalles están en /es/posts/databases/como-funcionan-los-indices-de-base-de-datos/).

El lock optimista se salta el lock y comprueba un conflicto en el momento de escribir, normalmente con una columna de versión:

1
2
3
UPDATE accounts
SET balance = balance - 100, version = version + 1
WHERE id = 42 AND version = 7;

Si otra transacción ya actualizó la fila, version ya no es 7, cero filas coinciden, y tu aplicación lo detecta y reintenta con el valor actualizado. Esto escala mejor cuando los conflictos son raros. Solo funciona si la comprobación de versión y la escritura ocurren en la misma instrucción. Lee la versión, luego ejecuta un UPDATE separado basado en ella, y habrás vuelto a abrir el hueco que querías cerrar.

¿Cuál es la diferencia entre una race condition y un deadlock?

Las dos se archivan como “bugs de concurrencia”, y fallan de maneras opuestas:

Race conditionDeadlock
Qué pasaLa ejecución termina, con un resultado erróneo o inconsistenteLa ejecución se detiene; nada termina
CausaAcceso no sincronizado a estado compartidoDos o más locks esperados en un ciclo
Cómo la notasNúmeros que no cuadran, datos que “a veces” están malEl programa o la petición se queda colgado
Solución típicaLocks, operaciones atómicas, o eliminar el estado mutable compartidoOrden consistente de locks, timeouts

Una solución para una puede introducir la otra. Envolver un bug check-then-act en un lock elimina la race. Si ese código también toma un segundo lock, en un orden distinto al de otro camino de código, has cambiado una corrupción silenciosa de datos por un colgado. Adquiere los locks siempre en el mismo orden en todas partes, y mantén la sección bloqueada tan corta como permita la corrección.

¿Cómo detectas las race conditions antes de producción?

Las races rara vez aparecen en un test de un solo thread que corre solo en tu laptop. Son una causa habitual de tests flaky, el tipo que se trata en /es/posts/testing/pruebas-unitarias-vs-integracion-vs-end-to-end/. Un test que falla una vez de cada cincuenta bajo la carga de la CI y pasa siempre que lo repites en local merece sospecha antes de descartarlo como “flaky”.

Go incluye un detector para esto. Compila o testea con -race y el runtime instrumenta los accesos a memoria, marcando cualquier caso en que dos goroutines toquen la misma variable de forma concurrente y al menos una de ellas escriba.

1
2
go test -race ./...
go build -race -o myapp .

Solo detecta las races que realmente se ejecutan durante esa ejecución, así que cuanto más se parezcan tus tests a una carga concurrente real, más encuentra. También multiplica el uso de CPU y memoria varias veces, así que tiene sentido en CI, no en el binario de producción. Python y Java no tienen nada integrado que lo iguale, una razón más para confiar en locks y operaciones atómicas de base de datos en vez de testear para ganar confianza.

Las cachés esconden el mismo bug. Dos peticiones que comprueban la misma clave buscando un valor viejo y luego la actualizan son otra vez check-then-act, y es un hueco que el patrón cache-aside de /es/posts/backend/cache-redis-como-funciona/ tiene que contemplar.

Antes de recurrir a un lock, sin embargo, pregúntate si el estado mutable compartido necesita existir siquiera. Un saldo actualizado solo mediante una única instrucción atómica como UPDATE accounts SET balance = balance - 100 WHERE id = 42 AND balance >= 100 no tiene ningún hueco entre check y act, porque la base de datos hace las dos cosas en un solo paso. La solución más barata para una race condition es eliminar el segundo camino que competía contra el primero, así que empieza tu próxima revisión listando cada sitio que escribe sobre ese estado.

Artículos relacionados