Saltar al contenido
Volver al archivo
Ingeniería7 min de lecturaPaso 13 de 22

Concurrencia: race condition, lock y deadlock

Los bugs que no pasan en tu máquina, no pasan en las pruebas, y pasan en producción. Cómo reconocerlos leyendo código.

El bug de concurrencia tiene una característica cruel: depende del tiempo. De que dos cosas ocurran en el orden equivocado, lo que es raro. Pero con un millón de peticiones al día, raro es todos los días.

No se reproduce en tu máquina. No aparece en las pruebas. Aparece en producción, desaparece, y vuelve en dos semanas.

La buena noticia es que la variedad es pequeña. Prácticamente todo bug de concurrencia cae en un puñado de patrones, y se pueden reconocer leyendo código.

Concurrencia no es paralelismo

La distinción es de Rob Pike, y es útil:

Concurrencia es lidiar con varias cosas a la vez: estructura del programa. Organizas el código en tareas que pueden progresar de forma independiente. Tiene sentido incluso en un solo núcleo: mientras una espera el disco, la otra trabaja.

Paralelismo es hacer varias cosas a la vez: ejecución simultánea de verdad, que exige más de un núcleo.

Por qué importa: un programa concurrente en un solo núcleo ya resuelve el problema de I/O, que es el caso de la mayoría de las aplicaciones web. Y lo inverso también es cierto: poner más núcleos en un programa mal estructurado no acelera nada.

Race condition: el ejemplo canónico

contador++

Parece una operación. Son tres: leer el valor de la memoria, sumar uno, grabar de vuelta.

Dos hilos, valor en 10:

A lee 10. B lee 10. A suma → 11. B suma → 11. A graba 11. B graba 11.

Dos incrementos, y el contador pasó de 10 a 11. Uno desapareció. Sin error, sin excepción, sin log.

La sección crítica es el tramo que toca estado compartido y necesita correr sin interrupción. La solución es exclusión mutua: uno solo a la vez ahí dentro.

Y el punto que separa a quien entiende de quien memorizó: el problema no es la variable compartida. Es la variable compartida mutable.

Un dato inmutable nunca tiene race. Por eso los lenguajes funcionales sufren menos, y por eso, en muchos casos, la mejor solución no es poner un lock, sino dejar de compartir. Pasar mensajes en vez de compartir memoria.

Antes de salir poniendo mutex, pregunta: ¿ese estado necesita de verdad ser compartido?

Los primitivos, y cuándo usar cada uno

Mutex: uno solo a la vez. Es lo que usas en el noventa por ciento de los casos.

Semáforo: como máximo N a la vez. Sirve para limitar recursos: como máximo diez llamadas simultáneas a esa API.

Lock de lectura y escritura: varios lectores a la vez, o un escritor solo. Vale cuando la lectura domina de verdad; si la escritura es frecuente, el coste extra no compensa.

Spinlock: en vez de dormir, el hilo se queda girando y comprobando. Solo tiene sentido cuando la espera es de nanosegundos, como dentro de un kernel. En código de aplicación, el spinlock casi siempre es un error.

Operaciones atómicas: el procesador ofrece instrucciones que hacen leer-modificar-grabar sin interrupción. La más importante es el compare-and-swap (CAS): "si el valor todavía es X, cámbialo por Y; si no, avísame que cambió".

Con CAS construyes algoritmos sin lock: lee, calcula, intenta cambiar, y si falló, inténtalo de nuevo.

Ventaja: ningún hilo bloquea a otro. Desventaja: con mucha contención, todo el mundo repite trabajo, y el rendimiento puede acabar peor que con un mutex simple.

Y existe el problema ABA: el valor cambió de A a B y volvió a A. El CAS cree que no pasó nada. Se resuelve con un contador de versión junto al valor.

La regla práctica: usa lo más simple que resuelva. Mutex primero. Atómico cuando el mutex se convierta en un cuello de botella comprobado por medición. Lock-free solo si tienes certeza de que lo necesitas, y probablemente no lo necesitas.

  1. Mutexuno a la vezResuelve el noventa por ciento de los casos. Es por donde se empieza.
  2. Semáforocomo máximo NLimita recursos: como máximo diez llamadas simultáneas a esa API.
  3. Lock de lectura y escrituradomina la lecturaVarios lectores, o un escritor solo. Si la escritura es frecuente, el coste extra no compensa.
  4. Operaciones atómicassin bloquearCompare-and-swap: lee, calcula, intenta cambiar. Con mucha contención, todos repiten trabajo.
  5. Spinlockcasi siempre errorSolo cuando la espera es de nanosegundos, como dentro de un kernel. En aplicación, no.
Usa lo más simple que resuelva. Lock-free solo con la medición en la mano.

Deadlock y la regla que lo resuelve

El hilo A tiene el lock 1 y quiere el 2. El hilo B tiene el lock 2 y quiere el 1. Nadie suelta, nadie avanza.

Coffman mostró que el deadlock necesita cuatro condiciones simultáneas: exclusión mutua, retener y esperar, ausencia de expropiación, y espera circular. Rompe cualquiera y no hay deadlock.

En la práctica rompes la cuarta, y la regla cabe en una frase:

Adquiere siempre los locks en el mismo orden global.

Si todo el mundo coge el lock 1 antes que el 2, nunca hay ciclo. Define el orden (por dirección de memoria, por nombre, por id) y documéntalo. Es una convención que elimina una clase entera de bugs.

Segunda línea de defensa: timeout. En vez de esperar para siempre, espera como máximo N y falla. Cambias un bloqueo eterno por un error tratable.

Y eso vale también para la base de datos. Dos transacciones actualizando las mismas filas en orden invertido dan deadlock; la base lo detecta, elige una víctima y aborta. Misma solución: orden consistente de acceso a las filas, incluso añadiendo ORDER BY en la selección que precede al UPDATE.

El livelock es el primo: nadie se traba, pero nadie progresa. Dos personas en un pasillo intentando cederse el paso.

Starvation: un hilo nunca consigue su turno porque otros siempre llegan antes. Pasa con prioridades mal configuradas y con locks no justos.

El modelo de memoria

Este es el nivel que casi nadie conoce y que explica bugs imposibles.

El compilador y el procesador reordenan instrucciones para optimizar. En un solo hilo, el resultado es siempre el esperado. Entre hilos, no.

Un hilo puede ver las escrituras de otro en un orden distinto al que se hicieron. Inicializas un objeto y después publicas la referencia; otro hilo ve la referencia y el objeto a medio construir.

Las herramientas: volatile en Java y C# garantiza visibilidad: la lectura va a la memoria en vez de usar un valor en un registro. Las barreras de memoria impiden la reordenación. Y la relación "ocurre antes" (happens-before) es el vocabulario formal para razonar sobre esto.

La recomendación honesta: si estás pensando en el modelo de memoria, probablemente deberías estar usando una abstracción de más alto nivel: una cola concurrente de la biblioteca estándar, un actor, un canal.

La checklist de revisión de código

Esta es la que más bugs pilla en la práctica:

→ ¿Ese estado es compartido? Si lo es, ¿está protegido?

→ ¿Todos los caminos que lo tocan están protegidos, incluido el de error?

→ ¿El orden de adquisición de los locks es el mismo en todas partes?

→ ¿Hay operaciones de I/O dentro de la sección crítica? Si las hay, estás reteniendo un lock mientras esperas la red, lo que casi siempre está mal.

→ ¿Qué pasa si esa operación corre dos veces? Si la respuesta es mala, falta idempotencia.

→ ¿Hay timeout en toda espera?

→ ¿El recurso se cierra también en el camino de error?

→ ¿Existe una prueba que corra esto con concurrencia de verdad?

La herramienta que lo cambia todo

El detector de carreras. -race en Go, ThreadSanitizer en C y C++, herramientas equivalentes en Java y Rust.

Instrumentan el programa y detectan accesos concurrentes no sincronizados incluso cuando el bug no se manifiesta en esa ejecución. Encuentran en segundos lo que te llevaría semanas reproducir.

Si haces una sola cosa después de leer esto: pasa el detector de carreras por la suite de pruebas de tu proyecto. Si aparece algo, acabas de encontrar un bug que estaba esperando el momento adecuado.

Lee esto después

Háblame

¿Dudas sobre el artículo? Escríbeme por WhatsApp

Sin formulario y sin lista de correo. Si no estás de acuerdo con algo que escribí, o quieres contarme cómo lo resolviste, la conversación es directa conmigo.

Abrir conversación