wandres.dev
CONCURRENCIA Y ESTADO · carreras y transiciones

Las carreras lógicas: la respuesta lenta que pisa a la rápida

El navegador ejecuta un solo hilo y aun así una aplicación corrompe su propio estado con regularidad, porque la concurrencia que importa en una interfaz no vive en la memoria sino en el conjunto de operaciones pendientes. Esta lección disecciona el bug del buscador, dos peticiones en vuelo para dos consultas distintas y una única casilla de estado que acepta a quien llegue el último, para separar con precisión la carrera de datos, que el modelo de ejecución de JavaScript hace imposible, de la carrera lógica, que ese mismo modelo no previene en absoluto. Enuncia la invariante que el código ingenuo rompe, muestra por qué el fallo es casi invisible en desarrollo y sistemático en producción, y construye el vocabulario de petición en vuelo, respuesta obsoleta y orden de llegada frente a orden de intención sobre el que se apoyan las cuatro lecciones siguientes del nivel.

⏱ 18 min

Circula por los equipos de frontend una frase que es cierta en la letra y desastrosa en el espíritu: JavaScript tiene un solo hilo, así que aquí no hay carreras. Es verdad que dos funciones no se entrelazan a mitad de instrucción, que ninguna asignación se parte en dos y que nadie necesita un candado para incrementar un contador. Nada de eso protege de lo que ocurre de verdad en una interfaz moderna, donde el peligro no está dentro de un turno del bucle de eventos sino entre turnos: cinco peticiones pueden estar en vuelo simultáneamente, cada una con su latencia propia, y todas terminan escribiendo en la misma casilla de estado. El orden en que llegan lo decide la red, no la persona que teclea. La atomicidad del turno garantiza que ninguna escritura se corrompa a medias; no garantiza en absoluto que la última escritura sea la correcta. Esta lección abre el nivel nombrando ese fallo con precisión, porque casi todo lo que viene después consiste en negarle a la red el derecho a decidir cuál es la verdad.

🎯 Al terminar esta lección sabrás
  • Distinguir la carrera de datos, imposible en un solo hilo, de la carrera lógica, que ese hilo no impide.
  • Reconstruir paso a paso el bug del buscador y localizar el instante exacto en que se rompe la coherencia.
  • Enunciar la invariante violada como una relación entre la intención vigente y el estado visible.
  • Explicar por qué el fallo es raro en desarrollo, sistemático en producción y difícil de reproducir.

Una casilla y muchos escritores

La distinción que hay que fijar primero es de vocabulario, y no es pedantería porque de ella depende qué remedio se busca. Una carrera de datos es un acceso concurrente sin sincronización a la misma posición de memoria, con al menos un escritor; es un fenómeno del modelo de memoria y produce estados que ningún orden secuencial podría producir. El modelo de ejecución del navegador la elimina por construcción: un turno del bucle de eventos corre hasta el final sin que nadie lo interrumpa. Una carrera lógica es otra cosa por completo. Existen varias operaciones asíncronas pendientes, cada una fue lanzada bajo una premisa que era cierta cuando salió, y el resultado final depende del orden en que terminan. Cada escritura individual es perfectamente atómica. Lo que está mal es cuál gana.

// El buscador ingenuo. Cada pulsacion lanza una peticion nueva.
// La casilla resultados no pertenece a nadie: la ocupa quien llega el ultimo.
useEffect(() => {
  buscar(consulta).then(r => setResultados(r))
}, [consulta])

// Con consulta = "rea" y despues "react", el navegador tiene dos peticiones
// en vuelo. Si la de "rea" tarda 400 ms y la de "react" tarda 90 ms,
// el usuario ve la caja con react y la lista con los resultados de rea.

Conviene mirar ese efecto con desconfianza deliberada, porque su forma es la de un código correcto: hay una dependencia declarada, hay una promesa esperada, hay un asignador de estado. El defecto no está en ninguna línea, está en una suposición que no aparece escrita en ningún sitio y que el autor daría por evidente si se la preguntaran: que la respuesta que llega es la respuesta de la petición vigente. Esa suposición es un invariante implícito, y los invariantes implícitos son exactamente los que la concurrencia rompe primero. La única razón por la que el código parece funcionar es que en la mayoría de los casos las latencias son parecidas y el orden de llegada coincide con el orden de salida. Lo parecido no es lo garantizado.

📝
El bucle de eventos garantiza atomicidad, no consistencia

Merece la pena precisar qué compra exactamente el modelo de un solo hilo, porque el malentendido es extendido. Compra que ninguna función se interrumpa a mitad, que ninguna asignación se observe a medias y que ningún objeto se lea mientras otro lo está mutando. Todo eso son propiedades de un turno. La consistencia, en cambio, es una propiedad de la secuencia de turnos, y el bucle de eventos no ofrece garantía alguna sobre en qué orden se programan las continuaciones de varias promesas pendientes. Un lenguaje con hilos y candados tendría exactamente el mismo problema con el mismo código, porque el candado protege la casilla y aquí lo que está mal no es cómo se escribe la casilla, sino quién tiene derecho a escribirla.

Anatomía del bug del buscador

Reconstruyamos la secuencia con la lentitud suficiente para ver el punto de fractura. Alguien teclea rea y el efecto dispara una petición; la red la encamina hacia un servidor que en ese instante está atendiendo una consulta cara y la deja esperando. Doscientos milisegundos después la misma persona añade dos letras, react, y el efecto dispara una segunda petición que aterriza en un servidor descargado y responde en noventa milisegundos. La aplicación pinta los resultados de react y todo parece bien. Cuatrocientos milisegundos después de su salida, la primera petición vuelve del pasado con los resultados de rea y sobrescribe la casilla. La interfaz queda en un estado que no corresponde a ninguna intención del usuario: caja de texto con react, lista con rea.

sequenceDiagram
participant U as usuario
participant A as aplicacion
participant S as servidor
U->>A: teclea rea
A->>S: peticion P1 para rea
U->>A: teclea react
A->>S: peticion P2 para react
S-->>A: responde P2 en 90 ms
A->>A: estado pasa a resultados de react
S-->>A: responde P1 en 400 ms
A->>A: estado pasa a resultados de rea
Note over A: intencion react y estado rea

Lo característico de este fallo es que su probabilidad no es uniforme: se concentra donde nadie la mide. En una máquina de desarrollo, con el servidor en el mismo equipo y latencias de tres milisegundos, la ventana temporal en la que dos peticiones coexisten es tan estrecha que el bug prácticamente no aparece. En producción, con redes móviles, colas de conexión limitadas por dominio, servidores heterogéneos y caches que aciertan para unas consultas y fallan para otras, la dispersión de latencias es enorme y la inversión de orden deja de ser una anomalía. Un vistazo a la cola de la distribución basta para entenderlo: si la mediana está en ciento veinte milisegundos y el percentil noventa y nueve en dos segundos, cualquier par de peticiones separadas por menos de dos segundos puede invertirse.

// Antes de discutir el remedio conviene medir la anchura de la cola,
// porque la magnitud que gobierna el fallo no es la media sino la dispersion.
const muestras: number[] = []

async function buscarMedido(consulta: string) {
  const t0 = performance.now()
  const r = await buscar(consulta)
  muestras.push(performance.now() - t0)
  return r
}

// Sesion real de una tarde: p50 = 118 ms, p90 = 640 ms, p99 = 2100 ms.
// Regla practica: dos peticiones separadas por menos que p99 menos p50
// pueden llegar invertidas, y ese margen aqui es de casi dos segundos.

La conclusión que se extrae de esa medición desmonta la defensa más habitual del código ingenuo, que consiste en decir que el caso es tan improbable que no compensa el esfuerzo. La probabilidad de inversión no depende de la latencia media sino de la relación entre la separación temporal de dos peticiones y la anchura de la distribución, y como la separación la fija el ritmo del teclado mientras la anchura la fija la infraestructura, la primera es del orden de ciento cincuenta milisegundos y la segunda del orden de segundos. Cuando el fenómeno lento es dos órdenes de magnitud más disperso que el rápido, la inversión no es un caso extremo: es el comportamiento por defecto de una fracción sustancial de las sesiones, y esa fracción es mayor precisamente entre los usuarios con peor conexión, que son los que menos tolerancia tienen a que el producto además les mienta.

⚠️
El teclado escribe más rápido que la red

La cadencia de pulsación de una persona con soltura ronda los ciento cincuenta milisegundos por tecla, y las latencias reales de una petición contra un servicio de búsqueda están casi siempre por encima. La conclusión es incómoda pero exacta: escribir una palabra de ocho letras genera de forma rutinaria seis o siete peticiones simultáneas en vuelo. El estado ingenuo no es un sistema con una carrera ocasional, es un sistema con una carrera de siete participantes cada vez que alguien escribe algo. Que la salida sea correcta la mayoría de las veces es una propiedad estadística de tu red, no una propiedad de tu código, y por eso una mejora del servidor puede empeorar el bug en lugar de arreglarlo.

La invariante rota: intención frente a llegada

Nombrar el fallo con precisión exige subir un nivel de abstracción. Lo que el buscador debería garantizar es que el estado visible sea una función de la última intención expresada, y lo que garantiza el código ingenuo es que sea una función del último mensaje recibido. Son dos funciones distintas y solo coinciden cuando la red preserva el orden. Escrito así, el problema deja de ser un caso particular del autocompletado y se revela como la forma canónica de una familia entera de defectos, que reaparece con distinto disfraz cada vez que una acción del usuario dispara un trabajo cuyo resultado va a parar a un lugar compartido.

🌀

Lectura obsoleta

Dos consultas de lectura sobre la misma casilla. La respuesta vieja pisa a la nueva y el estado deja de corresponder al filtro visible. Es el caso del buscador y el más frecuente con diferencia.

✍️

Escritura perdida

Dos mutaciones sobre el mismo recurso. La que se resuelve la última fija el valor en el servidor, aunque su autor partiera de una copia anterior a la otra. Se trata en la tercera lección del nivel.

👻

Efecto zombi

La respuesta llega cuando el componente que la pidió ya no existe, o cuando la pantalla es otra. Escribir entonces no solo es inútil, puede resucitar estado descartado y filtrar memoria.

🔁

Doble envío

Dos pulsaciones del mismo botón antes de que la primera termine. El resultado no es un estado incoherente sino dos pedidos, dos cargos o dos correos. Aquí el orden no importa: importa la unicidad.

Vale la pena someter esa reformulación a una prueba de generalidad, porque su utilidad depende de que cubra casos que a primera vista parecen ajenos. Una pantalla que abre un cuadro de diálogo mientras una carga previa sigue en curso hereda el mismo defecto si esa carga escribe en el estado de la pantalla al volver. Un navegador de pestañas donde cada pestaña dispara su petición muestra el contenido de la penúltima si la última respondió antes. Un mapa que recarga marcadores al desplazarse pinta los marcadores de una región que ya no se ve. En los tres casos el diagnóstico es idéntico y la formulación abstracta lo captura sin esfuerzo, mientras que la formulación concreta, el bug del buscador, habría sugerido que se trataba de tres problemas distintos con tres soluciones distintas. Eso es exactamente lo que se gana al enunciar la invariante en lugar de memorizar el síntoma.

Estas cuatro variantes comparten estructura y no comparten remedio, y esa es la razón por la que conviene tenerlas separadas desde el principio. La lectura obsoleta se resuelve descartando lo viejo, que es lo que hace la segunda lección con AbortController y con tokens. La escritura perdida no se resuelve descartando nada, porque el dato ya está en el servidor; exige una noción de versión y una política explícita de conflicto. El efecto zombi se resuelve con limpieza y con una comprobación de vigencia en el momento de escribir. El doble envío se resuelve con idempotencia o con un cerrojo de envío, y una cancelación mal entendida incluso lo empeora, porque abortar la petición no aborta el trabajo que el servidor ya empezó.

Por qué no lo ves hasta que es tarde

Este defecto tiene una biografía típica que merece la pena conocer, porque explica por qué sobrevive tanto tiempo en bases de código maduras. Nace invisible, porque en desarrollo la ventana de solapamiento es mínima. Pasa las pruebas, porque los dobles de test resuelven de forma inmediata y en orden, de modo que el escenario que rompe el código nunca se ejecuta. Llega a producción como un informe raro y vago, del tipo la lista a veces muestra otra cosa, que nadie consigue reproducir y que se cierra por falta de información. Y se convierte en incidente el día que alguien introduce una cache que hace que unas consultas respondan en diez milisegundos y otras en ochocientos, porque justo entonces la dispersión de latencias se dispara y la inversión de orden pasa de rara a rutinaria.

// El test que hay que escribir antes de arreglar nada:
// forzar la inversion de orden y comprobar que el estado ignora lo viejo.
const lento = defer<Resultado[]>()
const rapido = defer<Resultado[]>()
servicio.buscar.mockImplementationOnce(() => lento.promesa)   // "rea"
servicio.buscar.mockImplementationOnce(() => rapido.promesa)  // "react"

escribir("rea")
escribir("react")
rapido.resolver([itemDeReact])   // llega antes la segunda
lento.resolver([itemDeRea])      // y despues la primera

expect(estado.resultados).toEqual([itemDeReact])  // falla en el codigo ingenuo

Ese test es el artefacto más valioso de la lección, más que cualquier arreglo, porque convierte un fenómeno probabilístico en un fenómeno determinista. Mientras el bug dependa de la red seguirá siendo una anécdota difícil de defender ante un equipo; en cuanto existe una prueba que lo produce siempre, se vuelve una obligación técnica con un criterio objetivo de cierre. Y como el escenario está descrito en términos de orden de resolución y no de tiempos, sigue siendo válido cuando cambies la implementación por señales, por una máquina de estados o por lo que venga después.

Hay además una razón metodológica para insistir en esta forma de prueba, y es que corrige un sesgo estructural de los dobles de test. Un simulacro que devuelve un valor ya resuelto colapsa toda la dimensión temporal del sistema: en el mundo de esa prueba, pedir y recibir ocurren en el mismo instante y por tanto no existe la categoría misma de petición en vuelo. Una batería completa construida sobre esa premisa puede tener cobertura total de líneas y no ejercitar jamás el único escenario en el que el código falla. Escribir los dobles con promesas diferidas, que se resuelven cuando el test lo decide, restituye esa dimensión y permite expresar afirmaciones que de otro modo son inexpresables, como que una respuesta tardía no debe alterar el estado o que una segunda pulsación no debe generar un segundo pedido.

💡
Nombra el fallo delante del equipo antes de arreglarlo

Un defecto sin nombre no entra en el vocabulario técnico de un grupo y por tanto no se previene, solo se repara cuando aparece. Llamarlo carrera lógica, distinguirlo en voz alta de la carrera de datos y describirlo siempre con la misma frase, la respuesta obsoleta que escribe donde no le corresponde, tiene un efecto que ninguna corrección puntual consigue: a partir de ese momento cualquier persona del equipo reconoce el patrón al leer una revisión de código, antes de que llegue a producción. Los nombres son infraestructura de prevención, y en concurrencia lo son más que en ninguna otra parte porque los síntomas son difusos y los informes de usuario nunca los describen bien.

La red no tiene derecho a decidir cuál es la verdad de tu interfaz

Detrás de este bug hay una confusión de autoridad que va mucho más allá del autocompletado, y merece la pena enunciarla como principio porque organiza el nivel entero. En un sistema ingenuo, la respuesta que llega escribe en el estado por el mero hecho de haber llegado, lo que equivale a conceder autoridad sobre la verdad de la interfaz a un actor que no sabe nada de la interfaz: la red, con sus colas, sus reintentos, sus caches y su latencia de cola larga. Nada en el diseño del transporte pretende preservar el orden de las intenciones de una persona, y sin embargo lo estamos usando como si lo hiciera. La corrección conceptual consiste en invertir la carga de la prueba: una respuesta no tiene derecho a escribir por haber llegado, tiene que demostrar que sigue siendo pertinente. Y demostrarlo exige que la aplicación mantenga en algún sitio la noción de cuál es la intención vigente, sea un número de secuencia, una bandera de vigencia, una clave de consulta o una señal de aborto. Toda la ingeniería de las cuatro lecciones que siguen es una elaboración de esa única idea. Ignorar la respuesta vieja, cancelarla, versionar la escritura, marcar la transición como pendiente y secuenciar las mutaciones son cinco técnicas distintas para una sola tesis: el estado es un registro de la intención del usuario, no un registro de la actividad de la red. Cuando esa distinción está clara en la cabeza del que diseña, los remedios concretos casi se deducen solos; cuando no lo está, se aplican como recetas y fallan en el primer caso que la receta no cubría.

⚔️ Provoca la carrera y conviértela en un test que no falle nunca
  1. Monta un buscador ingenuo contra un servicio simulado cuya latencia sea un valor aleatorio entre cincuenta y mil milisegundos, y escribe una palabra de ocho letras. Cuenta cuántas veces de veinte el estado final no corresponde a la caja de texto.
  2. Instrumenta cada petición con un identificador y registra en consola el instante de salida y el de llegada. Dibuja la inversión y calcula qué fracción de los pares sale desordenada.
  3. Escribe el test determinista de la lección con promesas diferidas y comprueba que falla contra la implementación actual. No lo arregles todavía.
  4. Clasifica tres puntos de tu aplicación real según la taxonomía de las tarjetas y anota, para cada uno, cuál de los cuatro remedios necesitaría.
  5. Añade una cache que responda en diez milisegundos para la mitad de las consultas y vuelve a medir la fracción de inversiones. Explica en una frase por qué mejorar la latencia media empeora la incidencia del fallo.
  6. Formula por escrito la invariante de tu pantalla en términos de intención vigente y comprueba si alguna otra casilla de estado de la misma pantalla la incumple también.