← Concurrencia y Sincronización Bloque 3 · Operaciones atómicas · Tema 14 de 47

Tema 14

Orden de memoria y visibilidad

Escribir un valor en una variable compartida no garantiza que otro hilo lo vea inmediatamente, ni en el orden en que se escribió. El compilador y el procesador reordenan las instrucciones para optimizar el rendimiento, y las cachés de cada núcleo pueden tener valores distintos. En este tema verás cómo funcionan los reordenamientos, qué es la visibilidad, por qué volatile no es sincronización y cómo las barreras de memoria y el memory_order de C11 resuelven el problema.

1. Qué es la visibilidad

La visibilidad es la garantía de que, cuando un hilo escribe un valor en una variable compartida, otros hilos ven ese valor (y no un valor obsoleto). Sin visibilidad, un hilo puede escribir un valor que otro hilo nunca ve, incluso si la escritura es atómica.

La visibilidad es un problema porque los procesadores modernos tienen cachés privadas por núcleo. Cuando un hilo escribe en una variable, el valor se escribe en la caché del núcleo donde se ejecuta. Otros núcleos pueden tener un valor diferente en su caché, y no ven el nuevo valor hasta que la caché se actualiza.

Además, el compilador y el procesador pueden reordenar las instrucciones, de modo que las escrituras no se ejecutan en el orden en que aparecen en el código.

2. El problema de la visibilidad

El problema de la visibilidad se ilustra con este ejemplo clásico:

visibilidad.c
int dato = 0;
int listo = 0;

/* Hilo 1: produce un dato. */
void productor(void) {
    dato = 42;          /* escribir el dato */
    listo = 1;          /* señalar que el dato está listo */
}

/* Hilo 2: consume el dato. */
void consumidor(void) {
    while (!listo) { }  /* esperar a que el dato esté listo */
    printf("%d\n", dato);  /* leer el dato */
}

El programador asume que, cuando listo = 1, el consumidor ve dato = 42. Pero esto no está garantizado:

El resultado puede ser que el consumidor imprima 0 en lugar de 42, aunque listo sea 1.

El reordenamiento es invisible

El reordenamiento no cambia el resultado de un programa de un solo hilo (el compilador y el procesador garantizan que el resultado sea el mismo). Pero en un programa concurrente, el reordenamiento puede causar que otro hilo observe un estado inconsistente.

3. Reordenamiento del compilador

El compilador optimiza el código reordenando las instrucciones, siempre que el resultado del programa de un solo hilo no cambie. Por ejemplo:

reordenamiento del compilador
/* Código fuente. */
a = 1;
b = 2;

/* El compilador puede generar: */
b = 2;
a = 1;

/* Porque en un solo hilo, el resultado es el mismo. */

El compilador también puede eliminar operaciones que considera innecesarias:

eliminación de código
/* Código fuente. */
x = 5;
x = 10;  /* el compilador puede eliminar la primera escritura */

/* Código generado. */
x = 10;

Y puede mantener variables en registros en lugar de escribirlas a memoria:

registro vs. memoria
/* Código fuente. */
counter++;
printf("%d\n", counter);

/* El compilador puede mantener counter en un registro: */
/* R1 = counter; R1 = R1 + 1; printf("%d\n", R1); */
/* Sin escribir a memoria hasta el printf. */

En un programa concurrente, estas optimizaciones son peligrosas: otro hilo puede leer la variable de memoria y ver un valor obsoleto.

4. Reordenamiento del procesador

Los procesadores modernos también reordenan las instrucciones para mejorar el rendimiento. Esto se llama ejecución fuera de orden (out-of-order execution). El procesador ejecuta las instrucciones en el orden que maximiza el uso de los recursos, no en el orden del código.

Además, los procesadores tienen buffers de escritura (store buffers): las escrituras se almacenan en un buffer y se escriben a la caché/memoria más tarde. Esto permite que el procesador continúe ejecutando mientras las escrituras se completan.

El resultado es que las escrituras pueden no ser visibles para otros procesadores inmediatamente, y pueden no aparecer en el orden en que se ejecutaron.

Tipo de reordenamientoDescripciónEjemplo
Load-LoadDos lecturas se reordenan.Leer B antes de A, aunque A esté primero en el código.
Load-StoreUna lectura se reordena después de una escritura.Leer A después de escribir B.
Store-LoadUna escritura se reordena después de una lectura.Escribir B después de leer A.
Store-StoreDos escrituras se reordenan.Escribir B antes de A.

El reordenamiento Store-Load es el más problemático: una escritura puede retrasarse después de una lectura posterior, causando que otro hilo vea un estado inconsistente.

5. Modelos de memoria

Los procesadores tienen diferentes modelos de memoria que determinan qué reordenamientos son permitidos:

ModeloReordenamientos permitidosProcesadores
Secuencialmente consistente (SC)Ninguno. Todas las operaciones se ven en el mismo orden por todos los hilos.Teórico (no se implementa en hardware moderno).
TSO (Total Store Order)Store-Load. Las escrituras pueden retrasarse después de lecturas.x86, x86-64.
PSO (Partial Store Order)Store-Store, Store-Load.SPARC (modo RMO).
RMO (Relaxed Memory Order)Todos los reordenamientos.ARM, PowerPC, RISC-V.

x86 tiene el modelo más estricto (TSO): solo permite el reordenamiento Store-Load. ARM y PowerPC tienen modelos más relajados (RMO): permiten todos los reordenamientos. Esto significa que el código que funciona en x86 puede fallar en ARM.

x86 es más permisivo que ARM

En x86, las escrituras no se reordenan entre sí (Store-Store no se permite). En ARM, las escrituras pueden reordenarse. Por eso, código que funciona en x86 puede fallar en ARM. Usar las operaciones atómicas de C11 (que incluyen las barreras necesarias) evita este problema.

6. Por qué volatile no es sincronización

En C, el calificador volatile le dice al compilador que una variable puede cambiar en cualquier momento (por hardware, por señales, por otro hilo) y que no debe optimizar los accesos a ella. Pero volatile no es sincronización:

En Java y C#, volatile sí tiene semántica de memoria (garantiza visibilidad y orden). Pero en C y C++, volatile no tiene semántica de memoria para concurrencia. Es un error común asumir que volatile es suficiente para sincronización en C.

volatile no es suficiente
volatile int dato = 0;
volatile int listo = 0;

/* Hilo 1. */
dato = 42;
listo = 1;  /* El compilador o el procesador pueden reordenar: listo = 1 antes de dato = 42. */

/* Hilo 2. */
while (!listo) { }
printf("%d\n", dato);  /* Puede imprimir 0. */

Para garantizar la visibilidad y el orden, se necesitan operaciones atómicas con el memory_order adecuado, o barreras de memoria explícitas.

7. Barreras de memoria

Una barrera de memoria (memory barrier o fence) es una instrucción que obliga al procesador a completar todas las operaciones de memoria anteriores antes de ejecutar las siguientes. Previene el reordenamiento.

Hay dos tipos principales:

TipoQué previeneCuándo se usa
Barrera de adquisición (acquire)Las lecturas posteriores no se reordenan antes de la barrera.Después de adquirir un lock.
Barrera de liberación (release)Las escrituras anteriores no se reordenan después de la barrera.Antes de liberar un lock.
Barrera completa (full)Previene todos los reordenamientos.Cuando se necesita máxima seguridad.

Las barreras de memoria son costosas porque impiden la optimización del procesador. Pero son necesarias para garantizar la visibilidad y el orden en programas concurrentes.

En C11, las barreras se proporcionan a través de las operaciones atómicas con memory_order. Por ejemplo, atomic_store_explicit(&x, val, memory_order_release) incluye una barrera de liberación.

8. memory_order en C11

C11 proporciona seis niveles de orden de memoria:

OrdenDescripciónCostoUso típico
memory_order_relaxedSolo atomicidad. No garantiza orden ni visibilidad.Más barato.Contadores donde solo importa la atomicidad.
memory_order_consumeLas operaciones dependientes de la lectura no se reordenan.Barato.Raramente usado.
memory_order_acquireLas lecturas posteriores no se reordenan antes.Moderado.Adquirir un lock.
memory_order_releaseLas escrituras anteriores no se reordenan después.Moderado.Liberar un lock.
memory_order_acq_relCombina acquire y release.Más caro.Operaciones read-modify-write.
memory_order_seq_cstOrden secuencial consistente. El default.Más caro.Cuando se necesita máxima seguridad.

El orden por defecto es memory_order_seq_cst (secuencialmente consistente). Es el más seguro pero el más costoso. Para mejor rendimiento, se pueden usar órdenes más relajados, pero requieren más cuidado.

El patrón acquire-release es el más común para implementar mutex:

acquire-release
/* Adquirir el lock. */
atomic_store_explicit(&lock, 1, memory_order_acquire);
/* ... sección crítica ... */
/* Liberar el lock. */
atomic_store_explicit(&lock, 0, memory_order_release);

La barrera de adquisición asegura que las lecturas de la sección crítica no se reordenan antes de adquirir el lock. La barrera de liberación asegura que las escrituras de la sección crítica no se reordenan después de liberar el lock.

9. Un ejemplo desarrollado

El ejemplo del productor-consumidor del tema anterior, corregido con barreras de memoria:

visibilidad corregida
#include <stdatomic.h>

int dato = 0;
atomic_int listo = 0;

/* Hilo 1: produce un dato. */
void productor(void) {
    dato = 42;
    atomic_store_explicit(&listo, 1, memory_order_release);
}

/* Hilo 2: consume el dato. */
void consumidor(void) {
    while (!atomic_load_explicit(&listo, memory_order_acquire)) { }
    printf("%d\n", dato);  /* garantizado: imprime 42 */
}

Con memory_order_release en la escritura y memory_order_acquire en la lectura, se garantiza que:

10. Cómo lo hace Linux

Linux usa barreras de memoria extensivamente en el núcleo. Los mecanismos son:

En el espacio de usuario, las operaciones atómicas de C11 (stdatomic.h) proporcionan las barreras necesarias. No es necesario usar barreras explícitas en código de usuario: las operaciones atómicas las incluyen.

11. Simulador interactivo

Este simulador muestra el problema de la visibilidad y cómo las barreras lo resuelven:

Simulador · visibilidad y reordenamiento

12. Resumen

13. Preguntas de repaso

Intenta responder cada una antes de abrir la respuesta.

1. ¿Qué es la visibilidad?

La garantía de que las escrituras sean visibles para otros hilos.

2. ¿Por qué el compilador reordena las instrucciones?

Para optimizar el rendimiento, siempre que el resultado del programa de un solo hilo no cambie.

3. ¿Qué tipo de reordenamiento es el más problemático?

Store-Load: una escritura se retrasa después de una lectura posterior.

4. ¿Qué modelo de memoria usa x86?

TSO (Total Store Order): solo Store-Load se reordena.

5. ¿Qué modelo de memoria usa ARM?

RMO (Relaxed Memory Order): todos los reordenamientos.

6. ¿Por qué volatile no es sincronización en C?

Porque no garantiza atomicidad, visibilidad ni orden entre hilos.

7. ¿Qué es una barrera de memoria?

Una instrucción que obliga al procesador a completar las operaciones de memoria anteriores antes de ejecutar las siguientes.

8. ¿Qué es memory_order_release?

Las escrituras anteriores no se reordenan después de la operación atómica.

9. ¿Qué es memory_order_acquire?

Las lecturas posteriores no se reordenan antes de la operación atómica.

10. ¿Cuál es el memory_order por defecto en C11?

memory_order_seq_cst (secuencialmente consistente).

Para practicar

  1. Escribe un programa que demuestre el problema de visibilidad.
  2. Corrige el programa con barreras de memoria y verifica que funciona.
  3. Compara el rendimiento con memory_order_relaxed y memory_order_seq_cst.
  4. Investiga cómo se implementan las barreras en x86 y ARM.
  5. Explica por qué el código que funciona en x86 puede fallar en ARM.
  6. Investiga cómo Linux implementa las barreras de memoria (mb, rmb, wmb).