En el modelo de memoria de C11, la sincronización no se trata solo de que una operación sea “atómica” (que no se vea a medias), sino de cuándo los efectos de esa operación son visibles para otros hilos. Los modelos de memoria definen la relación happens-before (sucede-antes), que es el contrato que garantiza que una escritura en un hilo sea visible en otro.
Un procesador moderno y un compilador optimizador no ejecutan las instrucciones en el orden exacto que escribes en el código fuente; reordenan las operaciones para maximizar el uso de los pipelines y evitar que la CPU se quede esperando a la memoria lenta. Si bien esto es una victoria para el rendimiento, rompe la lógica de tus programas si intentas sincronizar datos mediante variables no atómicas. Los órdenes de memoria de stdatomic.h son las instrucciones que le das al compilador y al hardware para restringir estas optimizaciones de reordenamiento en puntos críticos.
Los órdenes de memoria se dividen según su fuerza. memory_order_relaxed es el más débil; garantiza la atomicidad de la operación individual, pero no impone ninguna restricción de orden sobre las operaciones de memoria adyacentes. Es ideal para contadores de estadísticas donde el valor exacto en un instante dado no es crítico para la lógica de control. Por el contrario, memory_order_acquire y memory_order_release forman un par de sincronización: un release en un hilo asegura que todas las escrituras previas en ese hilo sean visibles para otro hilo que realice un acquire sobre la misma variable atómica. Por último, memory_order_seq_cst (el valor por defecto de todas las operaciones atómicas) es el más fuerte; impone un orden secuencial global, como si todos los hilos estuvieran viendo un único hilo de ejecución para todas las operaciones seq_cst.
Debes usar acquire/release para implementar semáforos, mutexes o mecanismos de transferencia de datos (el patrón publish-subscribe). Usa seq_cst solo cuando necesites un ordenamiento total entre múltiples variables atómicas distintas, ya que su coste de rendimiento es significativamente mayor debido a las barreras de memoria (fences) necesarias en arquitecturas como ARM o PowerPC. Si aplicas un orden incorrecto, lo que realmente rompes es la consistencia de la memoria: podrías leer un valor “nuevo” en una variable pero ver datos “viejos” (o corruptos) en el resto de la estructura que esa variable supuestamente protegía.
#include <stdio.h>
#include <stdatomic.h>
#include <pthread.h> // [POSIX]
/* Estructura de datos compartida entre hilos */
typedef struct {
int payload; /* Dato no atómico (protegido por la flag) */
atomic_int flag; /* Variable de sincronización */
atomic_int stats_counter; /* Contador de estadísticas (relaxed) */
} shared_data_t;
/*
* Productor: Escribe el payload y luego publica la bandera.
*/
void* producer_thread(void* arg) {
shared_data_t* s = (shared_data_t*)arg;
s->payload = 42; // Escritura normal (no atómica)
/*
* memory_order_release: Garantiza que la escritura en s->payload
* "suceda-antes" de la escritura en s->flag. Cualquier hilo que
* haga un 'acquire' en s->flag verá el valor 42.
*/
atomic_store_explicit(&s->flag, 1, memory_order_release);
/*
* memory_order_relaxed: Solo garantiza que el incremento sea atómico.
* No importa el orden respecto al payload para este contador.
*/
atomic_fetch_add_explicit(&s->stats_counter, 1, memory_order_relaxed);
return NULL;
}
/*
* Consumidor: Espera a la bandera y lee el payload.
*/
void* consumer_thread(void* arg) {
shared_data_t* s = (shared_data_t*)arg;
/*
* memory_order_acquire: Si lee un 1, garantiza que todas las
* escrituras realizadas por el hilo que hizo el 'release'
* sean visibles en este hilo desde este punto.
*/
while (atomic_load_explicit(&s->flag, memory_order_acquire) != 1) {
// Busy-wait (espera activa)
}
/* Gracias al acquire/release, payload es 42 garantizado */
printf("Consumidor leído payload: %d\n", s->payload);
return NULL;
}
int main(void) {
shared_data_t s = {
.payload = 0,
.flag = 0,
.stats_counter = 0
};
pthread_t t1, t2;
pthread_create(&t1, NULL, producer_thread, &s);
pthread_create(&t2, NULL, consumer_thread, &s);
pthread_join(t1, NULL);
pthread_join(t2, NULL);
printf("Estadísticas (relaxed): %d\n",
atomic_load_explicit(&s.stats_counter, memory_order_relaxed));
return 0;
}
Análisis del ejemplo
En el producer_thread, la instrucción s->payload = 42 es una escritura de memoria convencional. Si usáramos memory_order_relaxed para s->flag, el compilador o la CPU podrían decidir que, como payload y flag no tienen relación aparente, es más eficiente escribir flag = 1 antes que payload = 42.
Al usar atomic_store_explicit(..., memory_order_release), establecemos un punto de barrera: todas las escrituras anteriores a este store deben ser visibles para cualquier hilo que ejecute un acquire sobre la misma dirección. En el consumer_thread, el atomic_load_explicit(..., memory_order_acquire) actúa como el receptor de esa sincronización. Al leer el 1 en s->flag, se establece la relación happens-before que garantiza que la lectura de s->payload devuelva el valor 42 y no un valor basura o 0.
El uso de atomic_fetch_add_explicit con memory_order_relaxed en s->stats_counter demuestra eficiencia. Como no necesitamos que el valor del contador sincronice otros datos (solo nos interesa que el conteo sea correcto), evitamos la sobrecarga de las barreras de memoria de release/acquire, permitiendo que el hardware optimice el acceso al bus.
El error frecuente
El error más sutil y peligroso en sistemas multihilo es asumir que relaxed es suficiente para la comunicación entre hilos. Considera este código erróneo en el productor:
/* ERROR: Uso de relaxed para sincronización de datos */ s->payload = 42; atomic_store_explicit(&s->flag, 1, memory_order_relaxed);
En una arquitectura de memoria débil como ARM, el procesador puede reordenar estas instrucciones. El consumidor podría ver flag == 1 pero leer payload == 0 porque la escritura de payload aún no se ha propagado a la caché visible o fue reordenada después de la flag. Este error no se detectará con un compilador (que lo considera legal) y es extremadamente difícil de debuguear, ya que puede funcionar perfectamente en x86 (que tiene un modelo de memoria más fuerte, Total Store Order) y fallar estrepitosamente solo en arquitecturas ARM o PowerPC. Herramientas como ThreadSanitizer (TSan) son capaces de detectar esta condición de carrera de datos (data race) incluso si el programa parece funcionar durante tus pruebas.
N° 99