Ir al contenido

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.

atomic_counter.cpp
#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.

acquire_release.cpp
#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.

cas_max.cpp
#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.

spinlock.cpp
#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;
}
Error común

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/POWER

Usa 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.

Buena práctica

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::is_lock_free().

Puntos clave
  • std::atomic proporciona 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 locks
  • std::atomic_flag es el unico tipo garantizado libre de locks en todas las plataformas
  • Por defecto usa seq_cst y 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`?