Operaciones Atomicas y Ordenamiento de Memoria
Comprende la programacion libre de locks con std::atomic. Domina los ordenamientos de memoria (relaxed, acquire, release, seq_cst), implementa un spinlock con atomic_flag y aprende el patron compare-and-swap (CAS).
Que Son las Operaciones Atomicas?
Una operacion atomica es indivisible — se completa completamente o no se ejecuta en absoluto, sin estado intermedio visible para otros hilos. std::atomic proporciona acceso atomico a un valor de tipo T sin requerir un mutex.
Considera un simple ++counter en un int regular compartido entre hilos. Esto en realidad son tres pasos: cargar, incrementar, almacenar. Otro hilo puede intercalarse entre estos pasos, causando una carrera de datos (comportamiento indefinido). Con std::atomic, el incremento es una unica operacion atomica — sin intercalacion, sin UB.
Pero los atomicos van mas alla de simplemente "operaciones indivisibles". Tambien controlan el ordenamiento de memoria — las reglas que gobiernan cuando las escrituras de un hilo se hacen visibles para otros hilos. Aqui es donde los atomicos se vuelven tanto potentes como sutiles.
Basicos de std::atomic
Las operaciones atomicas mas comunes son load(), store(), exchange() y fetch_add()/fetch_sub(). Tambien puedes usar operadores ++, --, +=, etc.
#include <iostream>
#include <atomic>
#include <thread>
#include <vector>
std::atomic<int> counter{0}; // atomic integer, initialized to 0
void increment(int n) {
for (int i = 0; i < n; ++i) {
counter.fetch_add(1, std::memory_order_relaxed);
// or simply: ++counter; (uses seq_cst by default)
}
}
int main() {
std::vector<std::thread> threads;
for (int i = 0; i < 10; ++i) {
threads.emplace_back(increment, 100'000);
}
for (auto& t : threads) t.join();
std::cout << "Counter: " << counter.load() << "\n";
// Always prints 1000000 — no data race, no mutex needed
return 0;
}Ordenamiento de Memoria Explicado
Las CPUs y compiladores modernos reordenan las operaciones de memoria para mejorar el rendimiento. En x86 esto es relativamente leve, pero en ARM/RISC-V es agresivo. Los ordenamientos de memoria le dicen al compilador y a la CPU que reordenamientos estan permitidos.
memory_order_seq_cst (por defecto) — El ordenamiento mas fuerte. Todos los hilos ven todas las operaciones seq_cst en el mismo orden total. Simple y seguro pero puede limitar la optimizacion.
memory_order_acquire — Se usa en cargas. Ninguna lectura/escritura de memoria en el hilo actual puede reordenarse antes de esta carga. Piensalo como: "todo lo que lea despues de este punto ve al menos lo que el hilo que libera escribio."
memory_order_release — Se usa en almacenamientos. Ninguna lectura/escritura de memoria en el hilo actual puede reordenarse despues de este almacenamiento. Piensalo como: "todo lo que escribi antes de este punto es visible para el hilo que adquiere."
memory_order_acq_rel — Combina acquire y release. Se usa en operaciones de lectura-modificacion-escritura como compare_exchange.
memory_order_relaxed — Sin garantias de ordenamiento mas alla de la atomicidad. La operacion es atomica pero puede reordenarse libremente. Solo usalo para contadores independientes o estadisticas donde el ordenamiento no importa.
Acquire-Release en la Practica
El patron acquire-release es el caballo de batalla de la programacion libre de locks. Un almacenamiento release en un hilo se sincroniza con una carga acquire en otro hilo, estableciendo una relacion happens-before.
#include <iostream>
#include <atomic>
#include <thread>
#include <cassert>
std::atomic<bool> data_ready{false};
int payload = 0; // non-atomic — protected by acquire-release
void producer() {
payload = 42; // write payload BEFORE the release store
data_ready.store(true, std::memory_order_release);
// release: all writes before this store are visible
// to a thread that does an acquire load of data_ready
}
void consumer() {
while (!data_ready.load(std::memory_order_acquire)) {
// spin — acquire: sees all writes before the release store
}
// guaranteed to see payload == 42
assert(payload == 42); // never fires
std::cout << "Payload: " << payload << "\n";
}
int main() {
std::thread t1(producer);
std::thread t2(consumer);
t1.join();
t2.join();
return 0;
}Patron Compare-and-Swap (CAS)
Compare-and-swap (compare_exchange_weak/compare_exchange_strong) es el bloque de construccion fundamental de las estructuras de datos libre de locks. Atomicamente verifica si el valor es igual a un valor esperado y, si es asi, lo reemplaza con un valor deseado.
#include <iostream>
#include <atomic>
#include <thread>
#include <vector>
std::atomic<int> max_value{0};
void update_max(int new_val) {
int current = max_value.load(std::memory_order_relaxed);
// CAS loop: keep trying until we succeed or new_val is not bigger
while (new_val > current) {
if (max_value.compare_exchange_weak(
current, // expected (updated on failure)
new_val, // desired
std::memory_order_relaxed)) {
break; // success
}
// On failure, 'current' is updated with the actual value
// Loop re-checks if new_val > current (maybe another thread won)
}
}
int main() {
std::vector<std::thread> threads;
for (int i = 0; i < 100; ++i) {
threads.emplace_back(update_max, i);
}
for (auto& t : threads) t.join();
std::cout << "Max: " << max_value.load() << "\n"; // always 99
return 0;
}Spinlock con std::atomic_flag
std::atomic_flag es el tipo atomico mas primitivo — garantizado libre de locks en toda plataforma. Solo soporta test_and_set() y clear(), haciendolo perfecto para un spinlock simple.
#include <iostream>
#include <atomic>
#include <thread>
#include <vector>
class SpinLock {
std::atomic_flag flag_ = ATOMIC_FLAG_INIT;
public:
void lock() {
while (flag_.test_and_set(std::memory_order_acquire)) {
// spin — consider adding a pause/yield hint:
// __builtin_ia32_pause() or std::this_thread::yield()
}
}
void unlock() {
flag_.clear(std::memory_order_release);
}
};
SpinLock spin;
int shared_data = 0;
void work(int n) {
for (int i = 0; i < n; ++i) {
spin.lock();
++shared_data; // protected by spinlock
spin.unlock();
}
}
int main() {
std::vector<std::thread> threads;
for (int i = 0; i < 4; ++i) {
threads.emplace_back(work, 100'000);
}
for (auto& t : threads) t.join();
std::cout << "shared_data: " << shared_data << "\n";
// prints 400000
return 0;
}memory_order_relaxed no proporciona ninguna garantia de ordenamiento mas alla de la atomicidad en si. Usarlo para proteger datos compartidos no atomicos (como una flag que guarda un payload) es un error:
// BUG: el almacenamiento/carga relaxed no sincroniza payload
payload = 42;
ready.store(true, memory_order_relaxed); // podria reordenarse ANTES de payload=42!
// Consumidor:
while (!ready.load(memory_order_relaxed)) {}
assert(payload == 42); // PUEDE FALLAR en ARM/POWERUsa memory_order_relaxed solo cuando la variable atomica este sola y no necesite sincronizar el acceso a otros datos. Para patrones de publicacion-consumo, usa release/acquire.
Comienza con memory_order_seq_cst (el por defecto) y solo relaja a ordenamientos mas debiles cuando el perfilado demuestre que es necesario. Los ordenamientos de memoria incorrectos producen errores que son casi imposibles de reproducir en x86 pero se manifiestan en ARM. Prefiere mutexes para estado compartido complejo — los atomicos son para variables compartidas simples y construir primitivas libre de locks. Siempre verifica las suposiciones de libre-de-locks con std::atomic.
std::atomicproporciona operaciones indivisibles sin mutexes — seguro para lecturas y escrituras concurrentes- El ordenamiento de memoria controla como las escrituras en un hilo se hacen visibles para otros hilos — no es solo sobre atomicidad
- Acquire-release es el patron clave: el almacenamiento release publica datos, la carga acquire los consume
compare_exchange_weak/strong(CAS) es el bloque de construccion para algoritmos libre de locksstd::atomic_flages el unico tipo garantizado libre de locks en todas las plataformas- Por defecto usa
seq_csty debilita solo cuando el perfilado lo exija — los errores sutiles son extremadamente dificiles de depurar
Quiz — Pon a prueba tus conocimientos
(25 XP)1. Que garantiza `memory_order_acquire` en una operacion de carga?
2. Por que `compare_exchange_weak` se usa tipicamente en un bucle?
3. Cuando es seguro usar `memory_order_relaxed`?