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.
#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 »).
#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.
#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).
#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 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/POWERN'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.
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.
std::atomicfournit 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 verroustd::atomic_flagest le seul type garanti sans verrou sur toutes les plateformes- Utilisez
seq_cstpar 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 ?