wandres.dev
SINCRONIZADOR · Concurrencia

Atómicos y el modelo de memoria

Qué garantiza exactamente el especificador atómico y qué no, el catálogo completo de órdenes de memoria con el patrón publicar y consumir, por qué el compilador y el procesador reordenan tu código con todo el derecho, y cómo la relación de precedencia sustituye a la noción de tiempo.

⏱ 22 min

El modelo de memoria es la parte de la norma de C que más gente usa sin haber leído y peor le sale. No describe cómo funciona un procesador: describe qué está permitido observar. Y su tesis central es incómoda: entre dos hilos no existe un ahora común, no existe un orden real de los acontecimientos, y preguntar qué ocurrió antes carece de sentido salvo que tú hayas construido explícitamente la relación que lo define.

🎯 Al terminar esta lección sabrás
  • Separar con precisión atomicidad, visibilidad y orden, que son tres garantías distintas.
  • Usar el catálogo de memory_order y aplicar el patrón publicar y consumir.
  • Explicar qué transformaciones hacen el compilador y el procesador y por qué son legales.
  • Razonar con la relación de precedencia en lugar de con el tiempo.

Atomicidad no es orden

_Atomic es un especificador de tipo que cambia el significado de todas las operaciones sobre el objeto. La cabecera es stdatomic.h y ofrece tanto los nombres cómodos como la forma general.

#include <stdatomic.h>

atomic_int contador = 0;          /* equivale a _Atomic int */
_Atomic(Punto) p;                 /* cualquier tipo puede llevarlo */

contador++;                       /* atomico y secuencialmente consistente */
atomic_fetch_add(&contador, 1);   /* lo mismo, explicito */
int v = atomic_load(&contador);
atomic_store(&contador, 7);
int viejo = atomic_exchange(&contador, 9);

Lo primero que hay que desmontar es la ecuación entre atómico y libre de candados. Un _Atomic sobre un tipo grande —una estructura de cuarenta bytes— es perfectamente legal y el compilador lo implementa tomando un candado interno de una tabla global. Las macros ATOMIC_INT_LOCK_FREE y compañía, junto con atomic_is_lock_free, te dicen la verdad; el único tipo cuya libertad de candados garantiza la norma es atomic_flag.

Lo segundo, más importante: atomicidad y orden son garantías independientes. Que una operación sea indivisible no dice nada sobre cuándo la verán los demás ni sobre qué otras operaciones tuyas ya serán visibles cuando la vean. Un atomic_int con memory_order_relaxed nunca produce un valor a medio escribir, y aun así permite que otro hilo observe las escrituras que lo rodean en cualquier orden.

Y una aclaración que hay que hacer siempre: volatile no sirve para esto. volatile impide que el compilador elimine o fusione un acceso, que es lo que necesita un registro de hardware o una variable tocada por un manejador de señal. No aporta atomicidad, no aporta orden entre hilos y no impide el reordenamiento del procesador. Usar volatile para comunicar hilos es un error de otra época que sigue compilando.

El catálogo de memory_order

Toda operación atómica acepta una versión _explicit con un orden. De más débil a más fuerte:

🪶

relaxed

Solo atomicidad y coherencia por objeto. Ningún orden con otras operaciones. Para contadores de estadísticas.

📤

release

En una escritura: todo lo que este hilo hizo antes queda publicado para quien adquiera después.

📥

acquire

En una lectura: todo lo publicado por el release que lee queda visible a partir de aquí.

🌐

seq_cst

Lo anterior más un orden total único sobre todas las operaciones seq_cst del programa.

El emparejamiento release con acquire es el patrón fundamental, y es el que hace segura la publicación de datos construidos por otro hilo:

static Config *config;                 /* dato normal, NO atomico */
static atomic_bool listo = false;

void productor(void) {
    config = construir_config();                        /* 1 */
    atomic_store_explicit(&listo, true, memory_order_release);   /* 2 */
}

void consumidor(void) {
    if (atomic_load_explicit(&listo, memory_order_acquire))      /* 3 */
        usar(config);                                   /* 4: ve lo de 1 */
}
flowchart TD
A[Productor escribe config] --> B[store release sobre listo]
B --> C[Consumidor hace load acquire de listo]
C --> D[Lee config y ve el valor construido]
E[Con relaxed en ambos extremos] --> F[Puede ver listo verdadero y config sin construir]
style D fill:#a6e3a1,color:#11111b
style F fill:#f38ba8,color:#11111b

La sutileza que separa a quien entiende esto de quien lo copia: la garantía no la da el atómico, la da la pareja. Si el consumidor lee con relaxed, el release del productor no sirve de nada y leer config es una carrera de datos con comportamiento indefinido, no una lectura de un valor viejo. Y el acquire solo arrastra lo publicado por el release cuyo valor ha leído; si lee otro valor, no hay sincronización.

memory_order_consume existe, pretendía dar una versión barata de acquire limitada a las dependencias de datos, y ninguna implementación real lo hace: todas lo elevan a acquire. La norma desaconseja usarlo. Trátalo como una nota histórica.

Por qué reordenan el compilador y el procesador

No es una travesura de los implementadores. La norma define la ejecución de un programa de un solo hilo por sus efectos observables, no por la secuencia de instrucciones, y eso concede una libertad enorme. El compilador promociona una variable a registro y su escritura en memoria se retrasa mil ciclos, saca del bucle una carga que considera invariante, fusiona dos escrituras contiguas, o adelanta una lectura para tapar la latencia de la caché. Todas esas transformaciones preservan el comportamiento del hilo aislado, y ninguna preserva lo que otro hilo alcanza a ver.

/* lo que escribes */            /* lo que el compilador tiene derecho a generar */
while (!bandera_normal)          if (!bandera_normal)
    ;                               for (;;) ;      /* la carga sale del bucle */

Ese bucle de espera sobre una variable ordinaria puede convertirse en un bucle infinito, legítimamente, porque en un solo hilo nada podría cambiar la bandera. Es el ejemplo más limpio de por qué la comunicación entre hilos necesita objetos atómicos y no basta con cuidado.

El procesador añade su propia capa. Cada núcleo escribe primero en un búfer de almacenamiento antes de que el valor llegue a la caché compartida, y ese búfer hace que el orden en que un núcleo publica sus escrituras no coincida con el orden en que otro las percibe. El grado de libertad depende de la arquitectura y las diferencias son grandes:

/* x86-64 es de orden total de almacenamiento: solo reordena escritura-lectura.
   Una carga acquire y un almacen release no cuestan ninguna instruccion extra;
   un almacen seq_cst se compila a xchg o exige mfence.                        */

/* AArch64 y POWER son debilmente ordenados: casi todo puede reordenarse.
   Alli acquire y release se compilan a ldar y stlr, y relaxed a ldr y str.    */

Ahí está la trampa profesional más frecuente: el código concurrente incorrecto funciona en x86 durante años y falla el día que compilas para ARM, porque en x86 casi todos los órdenes salen gratis y esconden el error. La única defensa es razonar sobre el modelo abstracto, no sobre la máquina que tienes delante.

Qué crea precedencia y cuánto cuesta

Los atómicos no son la única fuente de orden entre hilos, y de hecho no son la más frecuente. La norma define como constructoras de precedencia varias operaciones que ya usabas sin pensar en ellas:

  • Soltar un mutex precede a tomarlo después, así que la sección crítica siguiente ve todo lo que hizo la anterior.
  • Crear un hilo precede a todo lo que ese hilo ejecute, y todo lo que ejecute precede al thrd_join que lo recoge.
  • La función que ejecuta call_once precede a cualquier retorno posterior de call_once sobre la misma bandera.

Por eso el código de la lección de hilos podía leer los resultados parciales después del join sin ningún atómico: la arista ya estaba construida. Un mutex es, entre otras cosas, un acquire al entrar y un release al salir; toda la potencia del modelo de memoria estaba operando ahí en silencio.

Cuando necesitas orden y no hay ningún atómico donde colgarlo, quedan las vallas. atomic_thread_fence impone una barrera entre lo anterior y lo posterior sin tocar objeto alguno, y permite que un grupo de operaciones relaxed quede ordenado con una sola instrucción en lugar de con una por acceso:

for (size_t i = 0; i < n; i++)
    atomic_store_explicit(&salida[i], calcular(i), memory_order_relaxed);

atomic_thread_fence(memory_order_release);      /* una sola barrera */
atomic_store_explicit(&listo, true, memory_order_relaxed);

Es una optimización real y también una fuente inagotable de errores, porque el razonamiento con vallas es notoriamente más difícil que el razonamiento con órdenes adosados a la operación. La recomendación profesional es inequívoca: empieza siempre con seq_cst, que es lo que te dan las operaciones sin sufijo y el único orden con una semántica intuitiva —un orden total único que todos los hilos observan igual—, y baja a acquire con release únicamente donde el perfilado demuestre que el coste importa. Bajar a relaxed solo tiene justificación clara en contadores cuyo valor no ordena nada más, y las vallas son el último recurso.

No hay un ahora compartido: hay una relación de precedencia que tú construyes

La intuición que hay que desmontar es la del reloj universal. Casi todo el mundo imagina la memoria como una pizarra y los hilos como personas que escriben y leen en ella por turnos: pueden intercalarse de formas imprevisibles, pero existe una secuencia real de lo ocurrido, aunque nadie la conozca. Esa imagen es falsa, y su falsedad no es una limitación de la implementación sino la definición del modelo. El estándar de C no describe la memoria como un estado global que evoluciona en el tiempo; describe un conjunto de operaciones y un orden parcial entre ellas llamado precedencia, o happens-before. Dos operaciones de hilos distintos que no están relacionadas por esa precedencia sencillamente no tienen orden: no es que lo tengan y lo desconozcas, es que preguntar cuál fue primero es una pregunta sin referente, igual que en relatividad no hay respuesta a cuál de dos sucesos separados espacialmente ocurrió antes. Y de ahí se sigue todo lo demás con una limpieza que ninguna explicación mecánica alcanza. Se sigue por qué una carrera de datos es comportamiento indefinido y no un valor viejo: si dos accesos conflictivos no están ordenados por precedencia, la ejecución no tiene ningún significado definido que puedas discutir, ni siquiera uno malo. Se sigue por qué el release y el acquire funcionan en pareja: son literalmente el mecanismo con el que se crea una arista de precedencia entre dos hilos que no la tenían, y una arista requiere dos extremos. Se sigue por qué unir un hilo o tomar un mutex te da visibilidad de todo lo que hizo el otro: no porque esas operaciones vacíen cachés, sino porque la norma las define como constructoras de precedencia. Y se sigue la consecuencia práctica más útil de toda esta lección: en un programa concurrente, la sincronización no es una medida de protección que añades sobre unos datos, es la única fuente de orden que existe. Todo lo que no relacionas explícitamente queda desordenado por definición, y todo lo que crees saber sobre lo que ocurrió antes, sin una arista que lo respalde, es una suposición sobre una máquina concreta que un compilador nuevo o un procesador distinto te va a desmentir.

⚔️ Rompe la intuición del reloj universal
  1. Escribe el bucle de espera sobre una variable ordinaria, compílalo con -O2 y localiza en el ensamblador el bucle infinito. Cámbiala a atomic_bool y compara el código generado.
  2. Implementa el patrón publicar y consumir con release y acquire, y después degrada ambos a relaxed. Ejecútalo bajo -fsanitize=thread y comprueba que la versión relajada se denuncia como carrera de datos.
  3. Compila las tres variantes —relaxed, acquire con release, y seq_cst— y compara el ensamblador en x86-64 y en aarch64 con un compilador cruzado. Identifica dónde aparecen mfence, ldar y stlr.
  4. Escribe el caso de Dekker: dos hilos que escriben su bandera y leen la del otro. Demuestra con acq_rel que ambos pueden ver la del contrario a cero, y que con seq_cst deja de ser posible.
  5. Mide con un contador compartido de cien millones de incrementos el coste de relaxed, seq_cst y un mtx_t, con uno, dos, cuatro y ocho hilos. Explica la curva usando el rebote de la línea de caché entre núcleos.