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:
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:
- Reordenamiento del compilador: el compilador puede reordenar las escrituras:
listo = 1antes dedato = 42. - Reordenamiento del procesador: el procesador puede ejecutar
listo = 1antes dedato = 42si hay un cache miss endato. - Caché: el consumidor puede leer
datode su caché, que aún tiene el valor 0, aunque el productor ya escribió 42.
El resultado puede ser que el consumidor imprima 0 en lugar de 42, aunque listo sea 1.
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:
/* 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:
/* 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:
/* 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 reordenamiento | Descripción | Ejemplo |
|---|---|---|
| Load-Load | Dos lecturas se reordenan. | Leer B antes de A, aunque A esté primero en el código. |
| Load-Store | Una lectura se reordena después de una escritura. | Leer A después de escribir B. |
| Store-Load | Una escritura se reordena después de una lectura. | Escribir B después de leer A. |
| Store-Store | Dos 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:
| Modelo | Reordenamientos permitidos | Procesadores |
|---|---|---|
| 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.
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:
- No garantiza atomicidad:
volatile int counter; counter++;sigue siendo tres instrucciones.volatileno hace la operación atómica. - No garantiza visibilidad:
volatileno fuerza al procesador a escribir a memoria inmediatamente ni a invalidar la caché de otros procesadores. - No previene el reordenamiento: el compilador puede reordenar operaciones
volatilecon operaciones no volátiles. Y el procesador puede reordenarlas.
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 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:
| Tipo | Qué previene | Cuá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:
| Orden | Descripción | Costo | Uso típico |
|---|---|---|---|
memory_order_relaxed | Solo atomicidad. No garantiza orden ni visibilidad. | Más barato. | Contadores donde solo importa la atomicidad. |
memory_order_consume | Las operaciones dependientes de la lectura no se reordenan. | Barato. | Raramente usado. |
memory_order_acquire | Las lecturas posteriores no se reordenan antes. | Moderado. | Adquirir un lock. |
memory_order_release | Las escrituras anteriores no se reordenan después. | Moderado. | Liberar un lock. |
memory_order_acq_rel | Combina acquire y release. | Más caro. | Operaciones read-modify-write. |
memory_order_seq_cst | Orden 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:
/* 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:
#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:
- La escritura de
dato = 42se completa antes delisto = 1(barrera de liberación). - La lectura de
datoocurre después de quelisto == 1(barrera de adquisición). - Por lo tanto, el consumidor siempre ve
dato = 42.
10. Cómo lo hace Linux
Linux usa barreras de memoria extensivamente en el núcleo. Los mecanismos son:
- mb() (memory barrier): barrera completa.
- rmb() (read memory barrier): barrera de lectura.
- wmb() (write memory barrier): barrera de escritura.
- smp_mb(), smp_rmb(), smp_wmb(): barreras solo entre CPUs (no en un solo CPU).
- barrier(): barrera del compilador (evita reordenamiento del compilador, no del procesador).
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
- La visibilidad garantiza que las escrituras sean visibles para otros hilos.
- El compilador y el procesador reordenan las instrucciones para optimizar.
- x86 usa TSO (solo Store-Load se reordena). ARM usa RMO (todos los reordenamientos).
volatileno es sincronización en C: no garantiza atomicidad, visibilidad ni orden.- Las barreras de memoria previenen el reordenamiento.
- C11 proporciona 6 niveles de
memory_order. - El patrón acquire-release es el más común para mutex.
- Linux usa barreras extensivamente en el núcleo.
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
- Escribe un programa que demuestre el problema de visibilidad.
- Corrige el programa con barreras de memoria y verifica que funciona.
- Compara el rendimiento con memory_order_relaxed y memory_order_seq_cst.
- Investiga cómo se implementan las barreras en x86 y ARM.
- Explica por qué el código que funciona en x86 puede fallar en ARM.
- Investiga cómo Linux implementa las barreras de memoria (mb, rmb, wmb).