El modelo de memoria de C++ define cómo las operaciones de escritura en una posición de memoria son visibles para otras hilos. No es solo una cuestión de “ver” el dato, sino de la secuencia en la que ese dato es visible. Cuando usamos std::atomic, std::memory_order le indica al compilador y a la CPU qué restricciones de reordenamiento debemos imponer.
En términos técnicos, std::memory_order controla la relación happens-before (ocurre-antes). Sin estas restricciones, el compilador podría mover una escritura de un objeto no atómico después de una escritura atómica para optimizar registros, o la CPU podría ejecutar instrucciones de forma desordenada (out-of-order execution) basándose en la disponibilidad de los datos en la caché.
Para entenderlo, debemos distinguir tres niveles de sincronización:
1. memory_order_relaxed: Solo garantiza la atomicidad (la operación es indivisible) y el orden de modificación para esa variable específica. No establece ninguna barrera para otras operaciones de memoria.
2. memory_order_acquire / memory_order_release: Establecen una sincronización entre hilos. Una operación release en un hilo A sobre una variable atómica, que es seguida por una operación acquire en un hilo B sobre la misma variable, crea una arista de sincronización. Todo lo que el hilo A escribió antes del release es garantizado como visible para el hilo B después del acquire.
3. memory_order_seq_cst (Sequential Consistency): Es el estándar por defecto. No solo aplica acquire/release, sino que impone un orden total global. Todos los hilos ven exactamente la misma secuencia de operaciones en todas las variables atómicas, lo cual es extremadamente costoso en arquitecturas con múltiples núcleos debido a la necesidad de sincronizar los buffers de escritura (store buffers) mediante mensajes de invalidación de caché.
Usarás relaxed para contadores de estadísticas donde el valor exacto en un instante dado no importa tanto como la atomicidad del incremento. Usarás acquire/release para implementar mecanismos de handoff (como semáforos o flags de señalización) donde un hilo prepara datos y otro los consume. El uso de seq_cst es la opción segura para la lógica de control compleja donde la consistencia global es crítica, pero su sobrecarga en sistemas de baja latencia puede ser prohibitiva. Si te equivocas y usas relaxed donde necesitas sincronización, el error será un data race que se manifestará como una lectura de datos corruptos o inconsistentes, un bug que suele ser invisible en entornos de depuración y que solo aparece en hardware con una fuerte consistencia de memoria (como ARM) o bajo cargas de trabajo extremas.
#include <atomic>
#include <thread>
#include <iostream>
#include <vector>
#include <cassert>
struct Payload {
int data[10];
};
class DataProducer {
public:
// El payload no es atómico, es un objeto pesado
Payload payload;
// El flag que señaliza que el payload está listo
std::atomic<bool> ready{false};
// Un contador simple para estadísticas
std::atomic<int> processed_count{0};
void produce() {
// 1. Preparamos los datos (no atómico)
for (int i = 0; i < 10; ++i) payload.data[i] = i * 10;
// 2. Publicamos el payload usando 'release'.
// Esto garantiza que todas las escrituras anteriores (el payload)
// sean visibles para quien haga un 'acquire' en 'ready'.
ready.store(true, std::memory_order_release);
// 3. Incrementamos un contador de estadísticas.
// 'relaxed' es suficiente: solo nos importa que el incremento
// sea atómico, no necesitamos sincronizar el estado de otras variables.
processed_count.fetch_add(1, std::memory_order_relaxed);
}
void consume() {
// 1. Esperamos a que el flag sea true usando 'acquire'.
// Esto crea la relación de sincronización con el 'release' de la otra parte.
while (!ready.load(std::memory_order_acquire)) {
// Spin-wait (en producción usarías una instrucción de pausa como _mm_pause)
}
// 2. Gracias al acquire, el payload es totalmente visible aquí.
// No hay riesgo de leer datos parciales o desactualizados.
int sum = 0;
for (int i = 0; i < 10; ++i) sum += payload.data[i];
std::cout << "Consumido sum: " << sum << " | Contador: "
<< processed_count.load(std::memory_order_relaxed) << std::endl;
}
};
int main() {
DataProducer producer;
// Hilo consumidor
std::thread consumer_thread(&DataProducer::consume, &producer);
// Hilo productor
producer.produce();
consumer_thread.join();
return 0;
}
Desglose del concepto
En el ejemplo, la clase DataProducer gestiona la transferencia de un Payload no atómico. El núcleo de la sincronización reside en la variable ready.
Cuando el hilo productor ejecuta ready.store(true, std::memory_order_release), el compilador y la CPU tienen prohibido reordenar la escritura de payload.data para que ocurra después de la escritura en ready. Sin este release, un procesador podría optimizar el código moviendo la escritura del flag hacia arriba, haciendo que el consumidor vea ready == true antes de que los datos del payload se hayan propagado a la caché principal.
Por otro lado, en consume(), ready.load(std::memory_order_acquire) actúa como una barrera de entrada. Garantiza que cualquier lectura de memoria que aparezca después en el código (como el bucle que suma payload.data) no se ejecute antes de que la carga de ready se complete. La combinación de store-release y load-acquire establece la relación synchronizes-with, que a su vez garantiza un happens-before entre la escritura del payload y su lectura.
La variable processed_count utiliza std::memory_order_relaxed. Esto es eficiente porque no necesitamos que el valor del contador sincronice otros datos; solo necesitamos que la operación de incremento sea atómica para evitar que dos hilos pierdan actualizaciones si intentaran incrementar el mismo contador simultáneamente.
El error frecuente
Un error clásico es intentar usar memory_order_relaxed para una bandera que protege datos no atómicos.
// ERROR: UB (Undefined Behavior) / Race Condition
std::atomic<bool> ready{false};
int data = 0;
// Thread A (Productor)
data = 42;
ready.store(true, std::memory_order_relaxed); // ¡ERROR!
// Thread B (Consumidor)
if (ready.load(std::memory_order_relaxed)) { // ¡ERROR!
assert(data == 42); // Esto puede fallar!
}
En este escenario, debido a la reordenación, el hilo B podría ver ready como true pero leer data como 0. Esto no es un error de compilación; es un error de lógica de memoria. Herramientas como ThreadSanitizer (TSan) con la bandera -fsanitize=thread en clang++ o g++ detectarán esto como un data race. El compilador no te avisará porque, desde su punto de vista, estás siguiendo las reglas del modelo de memoria de forma legal, pero estás violando las expectativas de consistencia de los datos entre hilos.
N° 100