Vai al contenuto

Operazioni atomiche e ordinamento della memoria

Comprendi la programmazione lock-free con std::atomic. Padroneggia gli ordinamenti della memoria (relaxed, acquire, release, seq_cst), implementa uno spinlock con atomic_flag e impara il pattern compare-and-swap (CAS).

Cosa sono le operazioni atomiche?

Un'operazione atomica è indivisibile — o si completa del tutto o non avviene affatto, senza stati intermedi visibili agli altri thread. std::atomic fornisce accesso atomico a un valore di tipo T senza richiedere un mutex.

Pensa a un semplice ++counter su un normale int condiviso tra thread. In realtà sono tre passi: load, incremento, store. Un altro thread può inserirsi tra un passo e l'altro, causando un data race (comportamento indefinito). Con std::atomic l'incremento è una singola operazione atomica — nessuna interferenza, nessun UB.

Ma le atomiche vanno ben oltre le semplici "operazioni indivisibili". Controllano anche l'ordinamento della memoria, cioè le regole che stabiliscono quando le scritture di un thread diventano visibili agli altri thread. È qui che le atomiche diventano tanto potenti quanto insidiose.

Le basi di std::atomic

Le operazioni atomiche più comuni sono load(), store(), exchange() e fetch_add()/fetch_sub(). Puoi usare anche gli operatori ++, --, += e così via.

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;
}

L'ordinamento della memoria spiegato

Le CPU e i compilatori moderni riordinano le operazioni di memoria per ragioni di prestazioni. Su x86 il fenomeno è relativamente contenuto, ma su ARM/RISC-V è aggressivo. Gli ordinamenti della memoria dicono al compilatore e alla CPU quali riordini sono consentiti.

memory_order_seq_cst (predefinito) — L'ordinamento più forte. Tutti i thread vedono tutte le operazioni seq_cst nello stesso ordine totale. Semplice e sicuro, ma può limitare le ottimizzazioni.

memory_order_acquire — Si usa sulle load. Nessuna lettura o scrittura di memoria del thread corrente può essere riordinata prima di questa load. Pensala così: "tutto ciò che leggo da qui in poi vede almeno quello che ha scritto il thread che ha eseguito la release".

memory_order_release — Si usa sulle store. Nessuna lettura o scrittura di memoria del thread corrente può essere riordinata dopo questa store. Pensala così: "tutto ciò che ho scritto fino a questo punto è visibile al thread che esegue l'acquire".

memory_order_acq_rel — Combina acquire e release. Si usa sulle operazioni di lettura-modifica-scrittura come compare_exchange.

memory_order_relaxed — Nessuna garanzia di ordinamento oltre all'atomicità. L'operazione è atomica, ma può essere riordinata liberamente. Usalo solo per contatori indipendenti o statistiche, dove l'ordinamento non conta.

Acquire-release in pratica

Il pattern acquire-release è il cavallo di battaglia della programmazione lock-free. Una store release in un thread si sincronizza con una load acquire in un altro thread, stabilendo una relazione 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;
}

Il pattern compare-and-swap (CAS)

Il compare-and-swap (compare_exchange_weak/compare_exchange_strong) è il mattone fondamentale delle strutture dati lock-free. Controlla atomicamente se il valore è uguale a un valore atteso e, in caso affermativo, lo sostituisce con il valore desiderato.

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 è il tipo atomico più primitivo — garantito lock-free su ogni piattaforma. Supporta solo test_and_set() e clear(), il che lo rende perfetto per un semplice spinlock.

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;
}
Insidia

memory_order_relaxed non offre alcuna garanzia di ordinamento oltre all'atomicità stessa. Usarlo per proteggere dati condivisi non atomici (come un flag che fa da guardia a un payload) è un bug:

// BUG: la store/load relaxed non sincronizza il payload
payload = 42;
ready.store(true, memory_order_relaxed);  // potrebbe essere riordinata PRIMA di payload=42!

// Consumatore:
while (!ready.load(memory_order_relaxed)) {}
assert(payload == 42);  // PUÒ FALLIRE su ARM/POWER

Usa memory_order_relaxed solo quando la variabile atomica sta a sé e non deve sincronizzare l'accesso ad altri dati. Per i pattern di pubblicazione e consumo, usa release/acquire.

Buona pratica

Parti da memory_order_seq_cst (il valore predefinito) e passa a ordinamenti più deboli solo quando il profiling dimostra che è necessario. Ordinamenti della memoria sbagliati producono bug quasi impossibili da riprodurre su x86, ma che si manifestano su ARM. Per stati condivisi complessi preferisci i mutex — le atomiche servono per variabili condivise semplici e per costruire primitive lock-free. Verifica sempre le tue ipotesi sull'assenza di lock con std::atomic::is_lock_free().

Concetti chiave
  • std::atomic fornisce operazioni indivisibili senza mutex — sicure per letture e scritture concorrenti
  • L'ordinamento della memoria controlla come le scritture di un thread diventano visibili agli altri thread — non riguarda solo l'atomicità
  • Acquire-release è il pattern chiave: la store release pubblica i dati, la load acquire li consuma
  • compare_exchange_weak/strong (CAS) è il mattone di base degli algoritmi lock-free
  • std::atomic_flag è l'unico tipo garantito lock-free su tutte le piattaforme
  • Usa seq_cst come impostazione predefinita e indeboliscila solo quando il profiling lo richiede — questi bug sottili sono estremamente difficili da diagnosticare

Quiz — Metti alla prova le tue conoscenze

(25 XP)

1. Cosa garantisce `memory_order_acquire` su un'operazione di load?

2. Perché `compare_exchange_weak` viene tipicamente usata dentro un ciclo?

3. Quando è sicuro usare `memory_order_relaxed`?