← Concurrencia y Sincronización Bloque 1 · El problema de la concurrencia · Tema 3 de 47

Tema 3

Tu primera condición de carrera

La condición de carrera es el error más simple y más frecuente de la programación concurrente: dos hilos acceden al mismo dato, al menos uno lo modifica, y el resultado depende del orden en que se intercalan sus instrucciones. En este tema escribirás el programa concurrente más sencillo que existe, un contador incrementado por varios hilos, y lo verás dar un resultado distinto en cada ejecución, casi siempre menor que el correcto. Es el «hola mundo» de las condiciones de carrera, y todo lo que sigue consiste en entender por qué ocurre y cómo evitarlo.

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.

El error más traicionero

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:

racer.c
#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:

terminal · el resultado cambia en cada ejecución
$ 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.

El resultado siempre es menor

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:

assembly · lo que hace counter++
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:

Dos intercalados: uno correcto y uno incorrecto. Intercalado correcto (resultado = 2) A: LOAD (0) A: ADD (1) A: STORE (1) B: LOAD (1) B: ADD (2) B: STORE (2) B lee el valor actualizado por A. Resultado correcto: 2. Intercalado incorrecto (resultado = 1) A: LOAD (0) B: LOAD (0) A: ADD (1) B: ADD (1) A: STORE (1) B: STORE (1) B lee antes de que A escriba. Ambos escriben 1. Se pierde un incremento.
El intercalado incorrecto se produce cuando ambos hilos leen el mismo valor antes de que alguno escriba.

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:

Atomicidad depende del hardware

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:

TipoDescripciónEjemplo
Check-then-actSe 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-writeSe 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 inseguraSe 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 eventosEl 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 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.

terminal · detectar con ThreadSanitizer
$ 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:

terminal · más hilos, más 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:

  1. 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
  2. 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
  3. Herramientas de análisis: ThreadSanitizer y Helgrind detectan carreras y mal uso de candados en programas reales. Linux

11. Resumen

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

  1. Compila y ejecuta racer.c varias veces. ¿El resultado es siempre el mismo?
  2. Modifica racer.c para usar 1, 2, 4, 8 y 16 hilos. ¿Cómo cambia la pérdida?
  3. Compila con -fsanitize=thread y ejecuta. ¿Qué reporta ThreadSanitizer?
  4. Usa objdump -d para ver las instrucciones de máquina de counter++.
  5. Modifica el programa para que el contador sea una variable local (en la pila). ¿El resultado cambia?
  6. Investiga qué es una condición de carrera de tipo check-then-act y da un ejemplo.