Aller au contenu

Opérations atomiques et ordonnancement mémoire

Comprenez la programmation sans verrou (lock-free) avec std::atomic. Maîtrisez les ordres mémoire (relaxed, acquire, release, seq_cst), implémentez un spinlock avec atomic_flag et découvrez le schéma compare-and-swap (CAS).

Qu'est-ce qu'une opération atomique ?

Une opération atomique est indivisible — elle s'effectue entièrement ou pas du tout, sans qu'aucun état intermédiaire ne soit visible des autres threads. std::atomic fournit un accès atomique à une valeur de type T sans nécessiter de mutex.

Prenons un simple ++counter sur un int ordinaire partagé entre threads. Il s'agit en réalité de trois étapes : lecture, incrémentation, écriture. Un autre thread peut s'intercaler entre ces étapes, ce qui provoque une data race (comportement indéfini). Avec std::atomic, l'incrémentation est une seule opération atomique — pas d'entrelacement, pas d'UB.

Mais les atomiques vont plus loin que de simples « opérations indivisibles ». Ils contrôlent aussi l'ordonnancement mémoire — les règles qui déterminent à quel moment les écritures d'un thread deviennent visibles pour les autres threads. C'est là que les atomiques deviennent à la fois puissants et subtils.

Les bases de std::atomic

Les opérations atomiques les plus courantes sont load(), store(), exchange() et fetch_add()/fetch_sub(). Vous pouvez aussi utiliser les opérateurs ++, --, +=, 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;
}

L'ordonnancement mémoire expliqué

Les processeurs et compilateurs modernes réordonnent les opérations mémoire pour gagner en performance. Sur x86, c'est relativement modéré, mais sur ARM/RISC-V, c'est agressif. Les ordres mémoire indiquent au compilateur et au processeur quels réordonnancements sont autorisés.

memory_order_seq_cst (par défaut) — L'ordre le plus fort. Tous les threads voient toutes les opérations seq_cst dans le même ordre total. Simple et sûr, mais peut limiter l'optimisation.

memory_order_acquire — Utilisé sur les lectures (loads). Aucune lecture/écriture mémoire du thread courant ne peut être réordonnée avant cette lecture. Voyez-le ainsi : « tout ce que je lis après ce point voit au moins ce que le thread qui a fait le release a écrit ».

memory_order_release — Utilisé sur les écritures (stores). Aucune lecture/écriture mémoire du thread courant ne peut être réordonnée après cette écriture. Voyez-le ainsi : « tout ce que j'ai écrit avant ce point est visible pour le thread qui fait l'acquire ».

memory_order_acq_rel — Combine acquire et release. Utilisé sur les opérations lecture-modification-écriture comme compare_exchange.

memory_order_relaxed — Aucune garantie d'ordre au-delà de l'atomicité. L'opération est atomique mais peut être librement réordonnée. À réserver aux compteurs indépendants ou aux statistiques pour lesquels l'ordre n'a pas d'importance.

Acquire-release en pratique

Le schéma acquire-release est la cheville ouvrière de la programmation sans verrou. Une écriture release dans un thread se synchronise avec une lecture acquire dans un autre thread, établissant une relation happens-before (« se produit avant »).

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

Le schéma compare-and-swap (CAS)

Le compare-and-swap (compare_exchange_weak/compare_exchange_strong) est la brique de base des structures de données sans verrou. Il vérifie atomiquement si la valeur est égale à une valeur attendue et, si c'est le cas, la remplace par une valeur souhaitée.

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 avec std::atomic_flag

std::atomic_flag est le type atomique le plus primitif — garanti sans verrou sur toutes les plateformes. Il ne prend en charge que test_and_set() et clear(), ce qui le rend parfait pour un simple spinlock (verrou à attente active).

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;
}
Piège

memory_order_relaxed ne fournit aucune garantie d'ordre au-delà de l'atomicité elle-même. L'utiliser pour protéger des données partagées non atomiques (comme un drapeau qui garde une charge utile) est un bogue :

// BOGUE : un store/load relaxed ne synchronise pas payload
payload = 42;
ready.store(true, memory_order_relaxed);  // peut être réordonné AVANT payload=42 !

// Consommateur :
while (!ready.load(memory_order_relaxed)) {}
assert(payload == 42);  // PEUT ÉCHOUER sur ARM/POWER

N'utilisez memory_order_relaxed que lorsque la variable atomique est isolée et n'a pas besoin de synchroniser l'accès à d'autres données. Pour les schémas de publication-consommation, utilisez release/acquire.

Bonne pratique

Commencez par memory_order_seq_cst (la valeur par défaut) et ne passez à des ordres plus faibles que lorsque le profilage prouve que c'est nécessaire. Des ordres mémoire incorrects produisent des bogues quasiment impossibles à reproduire sur x86 mais qui se manifestent sur ARM. Préférez les mutex pour un état partagé complexe — les atomiques servent aux variables partagées simples et à la construction de primitives sans verrou. Vérifiez toujours vos hypothèses de fonctionnement sans verrou avec std::atomic::is_lock_free().

Points clés
  • std::atomic fournit des opérations indivisibles sans mutex — sûres pour des lectures et écritures concurrentes
  • L'ordonnancement mémoire contrôle la façon dont les écritures d'un thread deviennent visibles pour les autres threads — il ne s'agit pas seulement d'atomicité
  • Acquire-release est le schéma clé : l'écriture release publie les données, la lecture acquire les consomme
  • compare_exchange_weak/strong (CAS) est la brique de base des algorithmes sans verrou
  • std::atomic_flag est le seul type garanti sans verrou sur toutes les plateformes
  • Utilisez seq_cst par défaut et n'affaiblissez l'ordre que si le profilage l'exige — les bogues subtils sont extrêmement difficiles à déboguer

Quiz — Testez vos connaissances

(25 XP)

1. Que garantit `memory_order_acquire` sur une opération de lecture ?

2. Pourquoi `compare_exchange_weak` est-il généralement utilisé dans une boucle ?

3. Quand peut-on utiliser `memory_order_relaxed` sans danger ?