1. Qué es una condición de carrera
Una condición de carrera (race condition) ocurre cuando el resultado de un programa depende del orden relativo en que se ejecutan las instrucciones de dos o más hilos o procesos que acceden a un dato compartido, y al menos uno de ellos lo modifica. El programa no tiene un resultado único: el resultado cambia según cómo el planificador intercale las instrucciones.
El nombre viene de la idea de una carrera: dos hilos «compiten» por acceder a un dato, y el ganador (el que accede primero) determina el resultado. Si el orden de llegada cambia, el resultado cambia.
Las condiciones de carrera son el error más frecuente de la programación concurrente porque son invisibles: el código parece correcto, compila sin errores y funciona en las pruebas, pero falla en producción de forma intermitente. No hay un mensaje de error ni un crash: simplemente se obtiene un resultado incorrecto.
Una condición de carrera no produce un error de compilación ni un crash. El programa termina normalmente, pero con un resultado incorrecto. Y como el resultado depende del intercalado, el error puede no aparecer en las pruebas y aparecer solo en producción, con carga.
2. El programa: un contador compartido
El programa más simple que exhibe una condición de carrera es un contador compartido incrementado por varios hilos. Cada hilo incrementa el contador N veces. El resultado esperado es N × (número de hilos), pero rara vez se obtiene:
#define _POSIX_C_SOURCE 200809L
#include <pthread.h>
#include <stdio.h>
#include <stdlib.h>
static volatile int counter = 0; /* compartido entre todos los hilos */
static void *incrementar(void *arg)
{
int n = *(int *)arg;
for (int i = 0; i < n; i++)
counter++; /* no es atómico */
return NULL;
}
int main(int argc, char *argv[])
{
int n = argc > 1 ? atoi(argv[1]) : 100000;
int hilos = argc > 2 ? atoi(argv[2]) : 4;
if (hilos < 1 || hilos > 64)
return 1;
pthread_t t[64];
for (int i = 0; i < hilos; i++)
if (pthread_create(&t[i], NULL, incrementar, &n) != 0) {
fprintf(stderr, "no se pudo crear el hilo %d\n", i);
return 1;
}
for (int i = 0; i < hilos; i++)
pthread_join(t[i], NULL);
printf("hilos=%d incrementos por hilo=%d esperado=%d obtenido=%d perdidos=%d\n",
hilos, n, hilos * n, counter, hilos * n - counter);
return 0;
}
El programa recibe dos argumentos: el número de incrementos por hilo y el número de hilos. Por defecto, 100.000 incrementos y 4 hilos.
El volatile se usa para evitar que el compilador optimice el acceso a counter, pero como viste en el tema 2, volatile no es sincronización. No evita la condición de carrera.
3. Ejecutar y observar
Compilamos y ejecutamos varias veces para ver el no determinismo:
$ gcc -std=c17 -Wall -Wextra -pthread -o racer racer.c
$ for i in 1 2 3 4 5 6 7 8; do ./racer 100000 4; done
hilos=4 incrementos por hilo=100000 esperado=400000 obtenido=287431 perdidos=112569
hilos=4 incrementos por hilo=100000 esperado=400000 obtenido=312094 perdidos=87906
hilos=4 incrementos por hilo=100000 esperado=400000 obtenido=275812 perdidos=124188
hilos=4 incrementos por hilo=100000 esperado=400000 obtenido=298453 perdidos=101547
hilos=4 incrementos por hilo=100000 esperado=400000 obtenido=305127 perdidos=94873
hilos=4 incrementos por hilo=100000 esperado=400000 obtenido=281904 perdidos=118096
hilos=4 incrementos por hilo=100000 esperado=400000 obtenido=320581 perdidos=79419
hilos=4 incrementos por hilo=100000 esperado=400000 obtenido=294317 perdidos=105683
El resultado es siempre menor que 400.000 y diferente en cada ejecución. Se pierden entre el 20 % y el 30 % de los incrementos. La pérdida se debe a que los incrementos se pisan: cuando dos hilos leen el mismo valor, ambos escriben el mismo resultado, perdiendo un incremento.
Fíjate en que el resultado nunca es mayor que el esperado: solo se pierden incrementos, no se crean. Esto es porque counter++ es un incremento, no una asignación arbitraria. Si fuera una asignación (counter = i), el resultado podría ser cualquier valor.
En una condición de carrera de incremento, el resultado siempre es menor o igual al esperado, nunca mayor. Esto es una pista útil para detectar el problema: si un contador compartido da resultados menores que los esperados, probablemente hay una condición de carrera.
4. Por qué ocurre
Como viste en el tema 2, counter++ no es una instrucción atómica. A nivel de máquina son tres instrucciones:
LOAD R1, [counter] ; leer el valor de counter a R1
ADD R1, R1, 1 ; incrementar R1
STORE R1, [counter] ; escribir R1 a counter
El planificador puede interrumpir un hilo entre cualquiera de estas instrucciones. Si dos hilos ejecutan counter++ simultáneamente, sus instrucciones se pueden intercalar de formas que producen resultados incorrectos.
El problema fundamental es que la operación de incremento no es atómica: no se ejecuta de forma indivisible. Entre la lectura y la escritura, otro hilo puede leer el mismo valor y escribir el mismo resultado.
5. Los intercalados posibles
Para dos hilos que ejecutan counter++ una vez cada uno, hay 6 formas de intercalar las 3 instrucciones de cada hilo (C(6,3) = 20, pero muchas son equivalentes). Las más interesantes son:
El intercalado incorrecto se produce cuando ambos hilos leen el mismo valor (0) antes de que alguno escriba. Entonces ambos suman 1 y ambos escriben 1. El segundo incremento se pierde.
Con N hilos y M incrementos, el número de intercalados posibles es enorme ( (3M)! / (3!)^M ), y solo unos pocos producen el resultado correcto. Por eso, con muchos hilos y muchos incrementos, el resultado es casi siempre incorrecto.
6. El concepto de atomicidad
Una operación es atómica si se ejecuta de forma indivisible: o se ejecuta completamente o no se ejecuta en absoluto. No hay punto intermedio en el que otro hilo pueda observar un estado parcial.
La condición de carrera ocurre porque counter++ no es atómico. Si fuera atómico, no habría condición de carrera: el planificador no podría interrumpir entre la lectura y la escritura.
Las operaciones atómicas son el fundamento de la sincronización. En los bloques 3 y 4 verás cómo el hardware proporciona operaciones atómicas (test-and-set, compare-and-swap) y cómo se usan para construir mutex y otros mecanismos.
Algunas operaciones son atómicas por naturaleza en ciertos procesadores:
- Lectura y escritura de un byte: en la mayoría de los procesadores, leer o escribir un byte es atómico.
- Lectura y escritura de un word: en procesadores de 32 bits, leer o escribir un word (32 bits) alineado es atómico. En 64 bits, un word de 64 bits alineado es atómico.
- Operaciones de incremento: no son atómicas en la mayoría de los procesadores. Requieren instrucciones especiales (LOCK INC en x86).
Lo que es atómico depende del procesador. En x86, un acceso a memoria alineado del tamaño de un word es atómico. En ARM, las garantías son diferentes. Por eso, para portabilidad, se usan las operaciones atómicas de C11 (stdatomic.h) o las de POSIX, que abstraen las diferencias del hardware.
7. Tipos de condiciones de carrera
Las condiciones de carrera se clasifican en varios tipos:
| Tipo | Descripción | Ejemplo |
|---|---|---|
| Check-then-act | Se verifica una condición y luego se actúa, pero la condición puede cambiar entre la verificación y la acción. | Verificar si un archivo existe y luego crearlo. |
| Read-modify-write | Se lee un valor, se modifica y se escribe. Otro hilo puede modificar el valor entre la lectura y la escritura. | counter++. Es el tipo más común. |
| Publicación insegura | Se publica un objeto antes de que esté completamente inicializado. | Un hilo crea un objeto y lo asigna a una variable compartida; otro hilo lo lee antes de que esté listo. |
| Orden de eventos | El resultado depende del orden en que ocurren eventos en distintos hilos. | Un hilo espera una señal de otro; si la señal llega antes de que el hilo empiece a esperar, se pierde. |
El tipo más común en la práctica es read-modify-write, como el ejemplo del contador. Los otros tipos aparecen en programas más complejos.
8. Cómo detectarlas
Las condiciones de carrera son difíciles de detectar porque son no deterministas. Las herramientas más usadas son:
- ThreadSanitizer (TSan): una herramienta de Google que instrumenta el código y detecta accesos concurrentes a datos compartidos. Se activa con
-fsanitize=threaden GCC/Clang. - Helgrind: una herramienta de Valgrind que detecta condiciones de carrera y mal uso de mutex.
- DRD: otra herramienta de Valgrind, más rápida que Helgrind pero menos completa.
ThreadSanitizer es la herramienta más usada en la práctica. Se compila con -fsanitize=thread y se ejecuta normalmente. Si hay una condición de carrera, TSan imprime un informe detallado.
$ gcc -std=c17 -Wall -Wextra -pthread -fsanitize=thread -g -o racer_tsan racer.c
$ ./racer_tsan 10000 2
==================
WARNING: ThreadSanitizer: data race (pid=12345)
Write of size 4 at 0x... by thread T2:
#0 incrementar racer.c:12
Previous read of size 4 at 0x... by thread T1:
#0 incrementar racer.c:12
Location is global 'counter' of size 4 at 0x...
==================
TSan detecta la carrera en counter++ y muestra qué hilos y qué líneas de código la causan. Es una herramienta invaluable para encontrar condiciones de carrera en código real.
9. Experimento: variar el número de hilos
Ejecutamos racer.c con distintos números de hilos para ver cómo afecta la pérdida:
$ for n in 1 2 4 8 16; do ./racer 100000 $n; done
hilos=1 incrementos por hilo=100000 esperado=100000 obtenido=100000 perdidos=0
hilos=2 incrementos por hilo=100000 esperado=200000 obtenido=138472 perdidos=61528
hilos=4 incrementos por hilo=100000 esperado=400000 obtenido=287431 perdidos=112569
hilos=8 incrementos por hilo=100000 esperado=800000 obtenido=412893 perdidos=387107
hilos=16 incrementos por hilo=100000 esperado=1600000 obtenido=523418 perdidos=1076582
Con 1 hilo no hay carrera (no hay concurrencia). Con más hilos, la pérdida aumenta: con 16 hilos se pierden más del 60 % de los incrementos. Cuantos más hilos compiten por el mismo dato, más frecuentes son las carreras.
Esto ilustra un principio fundamental: la contención por un dato compartido limita la escalabilidad. Añadir más hilos no acelera el programa si todos compiten por el mismo contador.
10. Cómo trabajaremos
El curso se apoya en lo que sabes de Procesos e Hilos y en el C de Programación en C para Sistemas Operativos. Combina tres tipos de actividad:
- Programas con pthreads en C: cada herramienta de sincronización se prueba con un programa real, primero mostrando el problema y después la solución. Compilan con
gcc -std=c17 -Wall -Wextra -pthread. C - Simuladores en JavaScript: como los errores de concurrencia no se reproducen a voluntad, los simuladores permiten recorrer los intercalados paso a paso, forzar el peor caso y ver el estado de cada hilo. JavaScript
- Herramientas de análisis: ThreadSanitizer y Helgrind detectan carreras y mal uso de candados en programas reales. Linux
11. Resumen
- Una condición de carrera ocurre cuando el resultado depende del orden de ejecución de hilos que acceden a un dato compartido.
counter++no es atómico: son tres instrucciones (LOAD, ADD, STORE).- El resultado es no determinista y casi siempre menor que el esperado.
- Cuanto más hilos compiten, más frecuentes son las carreras.
- Las condiciones de carrera son invisibles: no producen errores ni crashes.
- Se detectan con ThreadSanitizer y Helgrind.
volatileno es sincronización.- La atomicidad es el concepto fundamental: una operación atómica se ejecuta de forma indivisible.
12. Preguntas de repaso
Intenta responder cada una antes de abrir la respuesta.
1. ¿Qué es una condición de carrera?
Un error que ocurre cuando el resultado depende del orden de ejecución de hilos que acceden a un dato compartido, y al menos uno lo modifica.
2. ¿Por qué counter++ no es atómico?
Porque son tres instrucciones de máquina: LOAD, ADD, STORE. El planificador puede interrumpir entre ellas.
3. ¿El resultado de una condición de carrera de incremento es siempre menor que el esperado?
Sí. Solo se pierden incrementos, no se crean.
4. ¿Por qué las condiciones de carrera son difíciles de reproducir?
Porque dependen del intercalado exacto, que cambia en cada ejecución.
5. ¿Qué es una operación atómica?
Una operación que se ejecuta de forma indivisible: o se ejecuta completamente o no se ejecuta en absoluto.
6. ¿volatile evita las condiciones de carrera?
No. Solo evita la optimización del compilador.
7. ¿Qué herramienta detecta condiciones de carrera en C?
ThreadSanitizer (con -fsanitize=thread) y Helgrind.
8. ¿Qué tipo de condición de carrera es counter++?
Read-modify-write.
9. ¿Cómo afecta el número de hilos a la frecuencia de la carrera?
Cuanto más hilos compiten, más frecuentes son las carreras.
10. ¿Por qué las condiciones de carrera son invisibles?
Porque no producen errores de compilación ni crashes. El programa termina normalmente con un resultado incorrecto.
Para practicar
- Compila y ejecuta
racer.cvarias veces. ¿El resultado es siempre el mismo? - Modifica
racer.cpara usar 1, 2, 4, 8 y 16 hilos. ¿Cómo cambia la pérdida? - Compila con
-fsanitize=thready ejecuta. ¿Qué reporta ThreadSanitizer? - Usa
objdump -dpara ver las instrucciones de máquina decounter++. - Modifica el programa para que el contador sea una variable local (en la pila). ¿El resultado cambia?
- Investiga qué es una condición de carrera de tipo check-then-act y da un ejemplo.