wandres.dev
SINCRONIZADOR · Concurrencia

Concurrencia: hilos, atomics y el modelo de memoria

Hilos estándar de C, operaciones atómicas y el modelo de memoria que gobierna qué ve cada hilo. La concurrencia correcta es de las cosas más difíciles y valiosas de C.

⏱ 16 min

Los procesadores modernos tienen muchos núcleos, y aprovecharlos significa concurrencia. Pero la memoria compartida entre hilos es un campo de minas: bugs no deterministas que aparecen una vez de cada millón. Dominar los hilos, los atomics y el modelo de memoria es de lo más difícil y respetado en programación de sistemas.

🎯 Al terminar esta lección sabrás
  • Hilos estándar de C (<threads.h>).
  • Condiciones de carrera y exclusión mutua.
  • Operaciones atómicas (<stdatomic.h>).
  • El modelo de memoria y los memory orders.

Hilos estándar

C11 estandarizó los hilos en <threads.h> (antes solo existía pthreads, específico de POSIX):

#include <threads.h>

int trabajo(void *arg) {
    int id = *(int *)arg;
    printf("hilo %d\n", id);
    return 0;
}

int main(void) {
    thrd_t h1, h2;
    int a = 1, b = 2;
    thrd_create(&h1, trabajo, &a);
    thrd_create(&h2, trabajo, &b);
    thrd_join(h1, nullptr);   // esperar a que terminen
    thrd_join(h2, nullptr);
}

Condiciones de carrera

El peligro central: dos hilos accediendo a la misma memoria a la vez, y al menos uno escribiendo. El resultado es indefinido:

int contador = 0;
// dos hilos haciendo contador++ un millón de veces cada uno
// contador++ NO es atómico: es leer, sumar, escribir.
// los hilos se pisan → el total final es menor que 2 millones (¡y varía!)

La solución clásica es un mutex (exclusión mutua): solo un hilo entra a la sección crítica cada vez.

mtx_t m;
mtx_init(&m, mtx_plain);
mtx_lock(&m);
contador++;              // protegido: un hilo a la vez
mtx_unlock(&m);

Atomics

Para operaciones simples, un atómico es más rápido que un mutex: el hardware garantiza que la operación ocurre indivisiblemente.

#include <stdatomic.h>
atomic_int contador = 0;
atomic_fetch_add(&contador, 1);   // incremento atómico, sin mutex
// o simplemente: contador++;  (sobre un atomic_int es atómico)

El modelo de memoria

El compilador y la CPU reordenan tu código

Aquí está una de las verdades más contraintuitivas y profundas de la programación de sistemas: el orden en que escribes las instrucciones no es el orden en que se ejecutan. El compilador reordena operaciones para optimizar, y la CPU las reordena y las cachea por núcleo. En un solo hilo no lo notas (el resultado es equivalente). Pero entre hilos, un núcleo puede ver las escrituras de otro en un orden distinto al que crees. Por eso “poner un flag booleano” para comunicar hilos falla de formas misteriosas: sin sincronización, no hay garantía de cuándo —ni si— otro hilo verá tu escritura. El modelo de memoria de C es el conjunto de reglas que define qué garantías tienes, y los memory orders de los atómicos son cómo pides exactamente la sincronización que necesitas. Este es el territorio donde viven los bugs de concurrencia más sutiles y donde se separa a los expertos de verdad.

Los atómicos aceptan un memory order que controla cuánta sincronización imponen (y cuánto rendimiento cuestan):

atomic_store_explicit(&listo, 1, memory_order_release);   // "publica" lo anterior
if (atomic_load_explicit(&listo, memory_order_acquire))    // "adquiere" lo publicado
    usar(datos);   // garantizado ver lo que el otro hilo escribió antes del release

memory_order_seq_cst (secuencialmente consistente, el más fuerte) es el seguro por defecto; acquire/release es el patrón para publicar/consumir datos con menos coste; relaxed no da garantías de orden (solo atomicidad).

⚠️
TSan es obligatorio aquí

La concurrencia con memoria compartida es tan propensa a errores que no debes escribirla sin ThreadSanitizer (nivel 24). -fsanitize=thread detecta condiciones de carrera que de otro modo aparecerían una vez de cada millón de ejecuciones, en producción, imposibles de reproducir. Corre siempre tus tests concurrentes bajo TSan. Y cuando puedas, prefiere diseños que eviten el estado mutable compartido (paso de mensajes, datos inmutables, particionar el trabajo): la concurrencia más fácil de hacer correcta es la que no comparte memoria.

⚔️ Concurrencia correcta
  1. Lanza dos hilos que incrementen un contador compartido sin protección y observa que el total varía y es incorrecto.
  2. Protégelo con un mtx_t y confirma que ahora es correcto.
  3. Reescríbelo con atomic_int y compara.
  4. Corre las versiones sin proteger bajo -fsanitize=thread y lee cómo TSan describe la carrera.