Modelo de memoria C11: happens-before y visibilidad

El modelo de memoria de C11 define la causalidad entre operaciones de memoria realizadas por diferentes hilos. En un entorno multihilo, no basta con que el código parezca secuencial; necesitamos un marco formal que dicte cuándo un cambio realizado por un hilo es visible para otro. Este modelo se basa en la creación de una relación llamada happens-before (sucede-antes), la cual se construye a partir de dos mecanismos: la relación sequenced-before, que es el orden de ejecución dentro de un mismo hilo (el orden de tus sentencias), y la relación synchronizes-with (sincroniza-con), que ocurre cuando una operación atómica en un hilo se comunica con otra operación atómica en un hilo distinto.

Para entender esto, piensa en el compilador y la CPU como entidades que buscan optimizar el rendimiento a toda costa. El compilador puede reordenar instrucciones si cree que no altera el resultado de un hilo único (sequenced-before), y la CPU puede ejecutar escrituras de forma asíncrona en sus buffers de salida. El modelo de memoria de C11 nos dice que, sin una relación de sincronización explícita, el hardware y el compilador tienen total libertad para que una escritura en el hilo A no sea visible para el hilo B, incluso si el hilo B parece haber “esperado” a que ocurra. El objetivo de usar tipos atómicos y barreras de memoria es forzar que esas operaciones se “sincronicen”, creando un puente de visibilidad.

Si no estableces estas relaciones correctamente, incurrirás en una data race (carrera de datos), que ocurre cuando dos hilos acceden al mismo objeto de forma concurrente, al menos uno de ellos es una escritura y no existe una relación de sincronización entre ellos. En C11, una data race no es simplemente un error de lógica; es comportamiento indefinido (UB). Si tienes una data race, el estándar te permite asumir que el programa es inválido; el compilador podría incluso optimizar eliminando código que considere “imposible” de ejecutar debido a la ausencia de la carrera, rompiendo toda la lógica de tu aplicación. Por eso, el uso de memory_order_seq_cst [C11] (el orden de consistencia secuencial) es el camino seguro porque garantiza un orden total global, pero es el más costoso a nivel de hardware ya que suele requerir barreras de memoria pesadas que vacían los buffers de la CPU.

El uso correcto de la memoria requiere identificar qué datos son compartidos y qué operaciones deben actuar como puntos de sincronización.

#include <stdatomic.h>
#include <stdio.h>
#include <pthread.h> // [POSIX]
#include <assert.h>

/* Datos compartidos no atómicos */
int payload = 0;
/* Flag atómico que servirá como señal de sincronización */
atomic_int ready = 0;

void* producer(void* arg) {
    (void)arg;
    // (1) Escritura en memoria no atómica
    payload = 42; 
    
    // (2) Sincronización: Release
    // Esta operación establece una relación 'synchronizes-with' 
    // con un load-acquire en otro hilo.
    atomic_store_explicit(&ready, 1, memory_order_release);
    
    return NULL;
}

void* consumer(void* arg) {
    (void)arg;
    // (3) Sincronización: Acquire
    // Esperamos a que el flag sea 1 usando semántica acquire.
    while (atomic_load_explicit(&ready, memory_order_acquire) == 0) {
        // Spin-wait (espera activa)
    }

    // (4) Lectura de datos
    // Gracias a la relación happens-before, el valor 42 es visible.
    if (payload == 42) {
        printf("Consumidor: Datos recibidos correctamente: %d\n", payload);
    } else {
        printf("Consumidor: ¡ERROR! Datos inconsistentes: %d\n", payload);
    }
    
    assert(payload == 42);
    return NULL;
}

int main(void) {
    pthread_t thread_p, thread_c;

    if (pthread_create(&thread_p, NULL, producer, NULL) != 0 ||
        pthread_create(&thread_c, NULL, consumer, NULL) != 0) {
        return 1;
    }

    pthread_join(thread_p, NULL);
    pthread_join(thread_c, NULL);

    return 0;
}

Análisis del mecanismo de visibilidad

En el ejemplo anterior, hemos evitado una data race mediante el uso de semántica release-acquire.

Fíjate en la línea (1) y (2). En el hilo productor, la escritura de payload está sequenced-before de la escritura atómica en ready. Al usar memory_order_release, le estamos diciendo al compilador y a la CPU: “ninguna operación de memoria que aparezca antes de esta puede ser reordenada para que ocurra después de la escritura atómica”. El atomic_store_explicit actúa como una barrera de salida.

En el hilo consumidor, la línea (3) utiliza memory_order_acquire. Esto garantiza que ninguna lectura de memoria que aparezca después en el código sea reordenada para ejecutarse antes de la carga de ready.

Cuando el consumidor detecta que ready == 1, se establece una relación synchronizes-with entre la línea (2) y la (3). Por transitividad, como la escritura de payload (1) está sequenced-before de (2), y (3) está sequenced-before de la lectura de payload (4), se establece una relación happens-before que garantiza que el valor 42 es visible en el hilo consumidor. Sin esta construcción, la CPU del consumidor podría haber “especulado” la lectura de payload antes de que ready fuera 1, leyendo un valor basura o un 0.

El error frecuente

Un error clásico es intentar usar volatile para la sincronización entre hilos:

/* ERROR: Código incorrecto */
volatile int flag = 0; 
int data = 0;

// Hilo A (Productor)
data = 42;
flag = 1; // Error: No es atómica, no hay barrera de memoria

// Hilo B (Consumidor)
while (flag == 0);
assert(data == 42); // UB: Data race y posible reordenamiento

volatile le dice al compilador que el valor de flag puede cambiar por factores externos (como un handler de señal o hardware), por lo que evita que el compilador cachee el valor en un registro. Sin embargo, volatile no es una herramienta de sincronización. No impide que la CPU reordene la escritura de data después de la escritura de flag, ni establece la relación synchronizes-with. Para detectar este error, herramientas como ThreadSanitizer (TSan) de GCC/Clang son imprescindibles, ya que detectarán la carrera de datos incluso si el código parece funcionar por “suerte” en tu arquitectura específica (como x86, que es fuertemente consistente, frente a ARM, que es débilmente consistente).

Si usas memory_order_seq_cst para todo por miedo, estarás pagando un impuesto de rendimiento innecesario en cada barrera de memoria.

131

Dejar un comentario

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *

Scroll al inicio