🔝 Retour au Sommaire
Les mutex sont puissants mais lourds : chaque acquisition implique une opération atomique, et en cas de contention, un appel système et un changement de contexte. Pour les opérations simples — incrémenter un compteur, basculer un flag, mettre à jour un pointeur — ce coût est disproportionné.
std::atomic offre une alternative : des opérations garanties indivisibles (atomiques) sur des types simples, sans mutex et sans possibilité de data race. Sous le capot, le compilateur émet des instructions matérielles spécifiques (comme LOCK XADD, CMPXCHG sur x86) qui effectuent l'opération en un seul cycle mémoire indivisible.
#include <atomic>
#include <thread>
#include <print>
std::atomic<int> counter{0};
void increment() {
for (int i = 0; i < 1'000'000; ++i) {
++counter; // Opération atomique — pas de data race
}
}
int main() {
std::thread t1(increment);
std::thread t2(increment);
t1.join();
t2.join();
std::println("counter = {}", counter.load()); // Toujours 2'000'000
}Comparé à la version mutex de la section 21.2.1, ce code est à la fois plus simple et significativement plus performant. Sur un x86-64 typique, un incrément atomique coûte environ 5-15 ns contre 15-25 ns pour une acquisition/relâchement de mutex — et l'écart se creuse sous contention.
std::atomic est un template qui encapsule un type sous-jacent et garantit que toutes les opérations sont atomiques :
#include <atomic>
std::atomic<int> a_int{0};
std::atomic<long> a_long{0L};
std::atomic<bool> a_bool{false};
std::atomic<double> a_double{3.14}; // C++20 pour les flottants
std::atomic<int*> a_ptr{nullptr};
// Alias pratiques
std::atomic_int ai{0}; // = std::atomic<int>
std::atomic_bool ab{false}; // = std::atomic<bool>
std::atomic_size_t as{0}; // = std::atomic<std::size_t> Un type T peut être utilisé avec std::atomic<T> s'il est :
- Trivialement copiable (
std::is_trivially_copyable_v<T> == true) : pas de constructeur de copie personnalisé, pas de destructeur virtuel, pas de pointeurs internes. Le type peut être copié bit-à-bit avecmemcpy.
En pratique, cela inclut tous les types primitifs (int, double, bool, pointeurs), les enums, et les petites structs POD :
struct Point {
float x, y;
};
// OK : Point est trivialement copiable
std::atomic<Point> a_point{{1.0f, 2.0f}};
struct Complex {
std::string name; // std::string n'est PAS trivialement copiable
};
// ❌ Erreur de compilation
// std::atomic<Complex> a_complex;Un std::atomic<T> n'est pas nécessairement lock-free. Si le type est trop grand pour une opération atomique matérielle (typiquement plus de 8 ou 16 octets selon l'architecture), l'implémentation utilise un mutex interne (spin lock). Vous pouvez vérifier à la compilation ou à l'exécution :
std::atomic<int> ai{0};
std::atomic<Point> ap{{0, 0}};
// Vérification à la compilation (C++17)
static_assert(std::atomic<int>::is_always_lock_free,
"int atomique devrait être lock-free");
// Vérification à l'exécution
std::println("atomic<int> lock-free : {}", ai.is_lock_free());
std::println("atomic<Point> lock-free : {}", ap.is_lock_free()); Sur x86-64 Linux, les types de 1, 2, 4 et 8 octets sont toujours lock-free. Les types de 16 octets le sont souvent (via CMPXCHG16B), mais ce n'est pas garanti partout.
Les opérations de base pour lire et écrire une valeur atomique :
std::atomic<int> value{42};
int v = value.load(); // Lecture atomique
value.store(100); // Écriture atomique
// Formes implicites (utilisent load/store en interne)
int v2 = value; // Conversion implicite → load()
value = 200; // Affectation → store() Les formes implicites sont pratiques mais les formes explicites load()/store() documentent mieux l'intention et permettent de spécifier le memory ordering (voir plus bas).
Écrit une nouvelle valeur et retourne l'ancienne, le tout de manière atomique et indivisible :
std::atomic<int> state{0};
int old = state.exchange(1); // old = 0, state = 1
// Aucun autre thread ne peut voir un état intermédiaireexchange() est utile pour implémenter des spin locks, des flags de prise de possession, ou tout mécanisme « swap atomique ».
Ces opérations effectuent une lecture-modification-écriture atomique et retournent la valeur avant la modification :
std::atomic<int> counter{10};
int old = counter.fetch_add(5); // old = 10, counter = 15
old = counter.fetch_sub(3); // old = 15, counter = 12
// Opérateurs surchargés (ne retournent PAS l'ancienne valeur)
counter += 7; // counter = 19 (retourne 19, pas 12)
counter -= 2; // counter = 17
++counter; // counter = 18 (retourne 18 = nouvelle valeur)
counter++; // counter = 19 (retourne 18 = ancienne valeur)
⚠️ Attention à la différence :fetch_add(n)retourne la valeur avant l'ajout, tandis queoperator+=retourne la valeur après. Et++counter(pré-incrément) retourne la nouvelle valeur, tandis quecounter++(post-incrément) retourne l'ancienne. Ces subtilités importent quand vous utilisez la valeur de retour pour prendre des décisions.
Disponibles pour les types entiers :
std::atomic<unsigned int> flags{0b0000};
flags.fetch_or(0b0010); // Active le bit 1 → 0b0010
flags.fetch_and(0b1110); // Désactive le bit 0 → 0b0010
flags.fetch_xor(0b0011); // Toggle bits 0 et 1 → 0b0001
// Opérateurs
flags |= 0b0100;
flags &= 0b1111;
flags ^= 0b0001; compare_exchange_strong et compare_exchange_weak sont les opérations les plus puissantes des atomiques. Elles implémentent le pattern Compare-And-Swap (CAS) :
« Si la valeur courante est égale à
expected, remplace-la pardesiredet retournetrue. Sinon, charge la valeur courante dansexpectedet retournefalse. Le tout de manière atomique. »
std::atomic<int> value{100};
int expected = 100;
bool success = value.compare_exchange_strong(expected, 200);
// success = true, value = 200, expected = 100 (inchangé)
expected = 100; // Mauvaise supposition
success = value.compare_exchange_strong(expected, 300);
// success = false, value = 200 (inchangé), expected = 200 (mis à jour)CAS est la brique de base de quasiment tous les algorithmes lock-free. Le pattern d'utilisation typique est une boucle CAS :
std::atomic<int> value{0};
void atomic_multiply_by_2() {
int expected = value.load();
while (!value.compare_exchange_weak(expected, expected * 2)) {
// expected a été mis à jour avec la valeur courante.
// On recalcule la valeur désirée et on réessaie.
}
}-
compare_exchange_strong: ne retournefalseque si la valeur ne correspond pas àexpected. Utilisation garantie en une seule tentative quand la valeur correspond. -
compare_exchange_weak: peut échouer de manière spurieuse — retournerfalsemême si la valeur correspond àexpected. Cet échec spurieux est autorisé car il permet une implémentation plus efficiente sur les architectures LL/SC (Load-Linked/Store-Conditional) comme ARM et RISC-V.
En pratique :
- Utilisez
weakdans une boucle CAS (la boucle gère naturellement les échecs spurieux). - Utilisez
strongquand un seul essai suffit ou quand le coût de recalculer la valeur désirée est élevé.
Sur x86-64, les deux variantes ont les mêmes performances (x86 fournit nativement CMPXCHG qui ne souffre pas d'échecs spurieux). La distinction importe surtout sur ARM.
Le cas d'usage le plus simple des atomiques est un flag de contrôle partagé entre threads :
#include <atomic>
#include <thread>
#include <print>
std::atomic<bool> running{true};
void worker() {
while (running.load()) {
do_work();
}
std::println("Worker arrêté proprement");
}
int main() {
std::thread t(worker);
std::this_thread::sleep_for(std::chrono::seconds(5));
running.store(false); // Signal d'arrêt
t.join();
}Ce pattern est correct et ne nécessite ni mutex ni variable de condition. Le flag atomique garantit que la modification par le thread principal est visible par le worker sans data race.
💡 C++20 offre un mécanisme plus structuré pour l'arrêt coopératif via
std::stop_tokenetstd::jthread(section 21.7), mais le flag atomique reste pertinent pour les cas simples ou le code pré-C++20.
std::atomic_flag est le seul type garanti lock-free par le standard. Il ne supporte que test_and_set() et clear(), ce qui le rend adapté à l'implémentation de spin locks :
#include <atomic>
std::atomic_flag lock_flag{}; // Initialisé à false/clear (C++20+)
void spin_lock() {
while (lock_flag.test_and_set(std::memory_order_acquire)) {
// Attente active — le flag est déjà set par un autre thread
}
}
void spin_unlock() {
lock_flag.clear(std::memory_order_release);
}
⚠️ Les spin locks sont rarement appropriés en code applicatif. Ils consomment 100% d'un cœur CPU pendant l'attente et dégradent les performances sous contention. Préférezstd::mutexsauf dans des contextes très spécifiques (sections critiques de quelques nanosecondes, code noyau, systèmes temps réel dur).
Depuis C++20, std::atomic_flag supporte aussi test() (lecture sans modification) et wait()/notify_one()/notify_all() qui en font un mécanisme de signalisation léger.
C'est la partie la plus complexe et la plus subtile des atomiques. Le memory ordering contrôle quelles garanties de visibilité une opération atomique fournit vis-à-vis des opérations mémoire environnantes.
Les processeurs modernes et les compilateurs réordonnent les accès mémoire pour optimiser les performances. Sur un seul thread, ces réordonnancements sont invisibles — le résultat est toujours « comme si » les instructions s'étaient exécutées dans l'ordre. Mais entre threads, sans barrières mémoire explicites, un thread peut observer les écritures d'un autre thread dans un ordre différent de celui du code source.
// Thread 1
data = 42; // (A)
flag.store(true); // (B)
// Thread 2
if (flag.load()) { // (C)
use(data); // (D) — data vaut-il forcément 42 ?
}Sans garanties de memory ordering, le processeur ou le compilateur pourrait réordonner (A) après (B), et le thread 2 verrait flag == true alors que data n'a pas encore été écrit. Le memory ordering permet de l'empêcher.
Le standard C++ définit six niveaux de memory ordering, du plus relâché au plus strict :
enum memory_order {
memory_order_relaxed,
memory_order_consume, // Rarement utilisé — voir note ci-dessous
memory_order_acquire,
memory_order_release,
memory_order_acq_rel,
memory_order_seq_cst // Par défaut
};Le séquentiellement consistant (sequentially consistent) est l'ordering par défaut de toutes les opérations atomiques. Il garantit un ordre total global : toutes les opérations seq_cst sont observées dans le même ordre par tous les threads, comme si elles avaient été exécutées séquentiellement sur une seule machine.
std::atomic<int> x{0}, y{0};
// Thread 1
x.store(1); // memory_order_seq_cst implicite
// Thread 2
y.store(1);
// Thread 3
if (x.load() == 1 && y.load() == 0) {
// Thread 3 a vu x=1 avant y=1
}
// Thread 4
if (y.load() == 1 && x.load() == 0) {
// Thread 4 a vu y=1 avant x=1
}
// Avec seq_cst : les threads 3 et 4 ne peuvent PAS tous les deux
// entrer dans leur if. L'un des deux ordres (x avant y, ou y avant x)
// est observé de manière cohérente par tous les threads.seq_cst est le choix le plus sûr et le plus intuitif. Utilisez-le par défaut. Le surcoût par rapport à des orderings plus faibles est nul sur x86-64 pour les loads et les stores, et minime pour les opérations read-modify-write.
💡 Conseil pragmatique : si vous devez vous demander quel memory ordering utiliser, la réponse est
seq_cst. Les orderings plus faibles sont des optimisations avancées qui ne se justifient que lorsque le profiling démontre un goulot d'étranglement et que vous maîtrisez parfaitement les implications.
Ce couple forme le pattern le plus courant après seq_cst. Il établit une relation happens-before entre un store (release) et un load (acquire) :
- Release : toutes les écritures mémoire effectuées avant ce store sont garanties visibles...
- Acquire : ...par tout thread qui effectue un load acquire et observe la valeur écrite par le release.
std::atomic<bool> flag{false};
int data = 0; // Variable non-atomique
// Thread 1 (producteur)
data = 42; // (A) — écriture ordinaire
flag.store(true, std::memory_order_release); // (B) — publication
// Thread 2 (consommateur)
while (!flag.load(std::memory_order_acquire)) {} // (C) — acquisition
// Ici, data == 42 est GARANTI // (D)
// Le release en (B) synchronise avec l'acquire en (C),
// donc (A) happens-before (D).C'est exactement le pattern « publier des données puis signaler qu'elles sont prêtes ». Le release est la barrière qui empêche les écritures antérieures de « fuir » après le flag, et l'acquire empêche les lectures postérieures de « remonter » avant le flag.
Acquire/release n'établit pas d'ordre total global (contrairement à seq_cst). Deux threads tiers pourraient observer les stores release dans des ordres différents. C'est suffisant pour la majorité des patterns producteur/consommateur, et légèrement plus performant que seq_cst sur les architectures ARM et RISC-V.
Combine acquire et release en une seule opération read-modify-write. Utilisé pour les opérations comme fetch_add ou compare_exchange qui lisent et écrivent simultanément :
std::atomic<int> counter{0};
// L'incrément est à la fois une acquisition et une publication
counter.fetch_add(1, std::memory_order_acq_rel);Aucune garantie d'ordering — seulement l'atomicité de l'opération elle-même. Les accès relaxed peuvent être réordonnés librement par le compilateur et le processeur par rapport aux autres opérations mémoire (atomiques ou non).
std::atomic<int> counter{0};
void count() {
// Relaxed : on se fiche de l'ordre par rapport aux autres opérations.
// On veut juste que l'incrément soit atomique.
counter.fetch_add(1, std::memory_order_relaxed);
}
int get_count() {
return counter.load(std::memory_order_relaxed);
}Les cas d'usage légitimes de relaxed sont limités :
- Compteurs de statistiques où la précision instantanée n'est pas critique.
- Génération d'identifiants uniques (la seule garantie nécessaire est l'absence de doublon).
- Implémentations de structures lock-free par des experts qui contrôlent précisément les dépendances.
⚠️ memory_order_relaxedne fournit aucune garantie de synchronisation. Si vous l'utilisez pour communiquer entre threads (« je mets un flag à true, l'autre thread le lit »), les données associées ne sont pas garanties visibles. C'est une source de bugs extrêmement subtils.
memory_order_consume est une version affaiblie de acquire qui ne garantit l'ordering que pour les lectures qui dépendent de la valeur chargée (data dependency). En théorie, c'est plus performant sur ARM car il exploite les dépendances matérielles sans barrière.
En pratique, aucun compilateur majeur n'implémente correctement consume en 2026. Tous le promeuvent silencieusement en acquire. Le comité C++ reconnaît le problème et travaille à une reformulation. En attendant, ne l'utilisez pas — utilisez acquire à la place.
Suis-je un expert en modèle mémoire C++ ?
├── Non → memory_order_seq_cst (le défaut, ne rien spécifier)
└── Oui
├── Pattern producteur/consommateur simple ?
│ └── release (store) + acquire (load)
├── Compteur de stats / ID unique ?
│ └── relaxed
├── Opération read-modify-write synchronisante ?
│ └── acq_rel
└── Besoin d'un ordre total observable par tous les threads ?
└── seq_cst
En résumé : partez toujours de seq_cst. N'affaiblissez l'ordering que si un benchmark prouve que c'est nécessaire, et seulement si vous comprenez précisément les implications.
Les petites structs trivialement copiables peuvent être rendues atomiques :
#include <atomic>
struct Config {
int timeout_ms;
int max_retries;
};
static_assert(std::is_trivially_copyable_v<Config>);
std::atomic<Config> current_config{{500, 3}};
void update_config(int timeout, int retries) {
current_config.store(Config{timeout, retries});
}
Config get_config() {
return current_config.load();
}Si sizeof(Config) est ≤ 16 octets sur x86-64, l'opération est typiquement lock-free. Au-delà, l'implémentation utilise un verrou interne — vérifiez avec is_always_lock_free.
std::atomic_ref permet d'effectuer des opérations atomiques sur une variable non-atomique existante, sans changer son type :
#include <atomic>
int regular_counter = 0;
void atomic_increment() {
std::atomic_ref ref(regular_counter);
ref.fetch_add(1);
}Cela est utile quand vous avez une structure de données existante que vous ne pouvez pas (ou ne voulez pas) modifier pour utiliser std::atomic, mais qui nécessite des accès atomiques dans certains contextes.
⚠️ Tant qu'unatomic_refexiste sur une variable, tous les accès à cette variable doivent passer par desatomic_ref. Mélanger des accès atomiques et non-atomiques sur la même variable est un comportement indéfini.
Le cas le plus classique — aucun mutex nécessaire :
class AtomicCounter {
std::atomic<int64_t> count_{0};
public:
void increment() { count_.fetch_add(1, std::memory_order_relaxed); }
void decrement() { count_.fetch_sub(1, std::memory_order_relaxed); }
int64_t get() const { return count_.load(std::memory_order_relaxed); }
};relaxed est approprié ici si le compteur est utilisé pour des statistiques et qu'on n'a pas besoin de synchroniser d'autres données autour de ses modifications.
#include <atomic>
#include <mutex>
#include <memory>
class Singleton {
static std::atomic<Singleton*> instance_;
static std::mutex mtx_;
public:
static Singleton* get() {
Singleton* p = instance_.load(std::memory_order_acquire);
if (!p) {
std::lock_guard lock(mtx_);
p = instance_.load(std::memory_order_relaxed);
if (!p) {
p = new Singleton();
instance_.store(p, std::memory_order_release);
}
}
return p;
}
};💡 En pratique, le Meyers' Singleton avec une variable
staticlocale est plus simple et tout aussi thread-safe depuis C++11 :Singleton& Singleton::get() { static Singleton instance; // Thread-safe depuis C++11 return instance; }
class SpinLock {
std::atomic<bool> locked_{false};
public:
void lock() {
while (locked_.exchange(true, std::memory_order_acquire)) {
// Attente active — hint au CPU pour économiser de l'énergie
while (locked_.load(std::memory_order_relaxed)) {
// Boucle interne de lecture seule (évite le bus locking)
}
}
}
void unlock() {
locked_.store(false, std::memory_order_release);
}
};La boucle interne avec load(relaxed) est une optimisation appelée TTAS (Test-and-Test-and-Set) : elle évite de générer des écritures sur le bus mémoire pendant l'attente, réduisant la contention cache entre cœurs.
| Critère | std::atomic |
std::mutex |
|---|---|---|
| Opération unique sur un type simple | ✓ | Surdimensionné |
| Section critique multi-instructions | Insuffisant | ✓ |
| Accès à une structure complexe | ✗ | ✓ |
| Performance sous faible contention | ~5-15 ns | ~15-25 ns |
| Performance sous forte contention | Se dégrade (spin) | Meilleur (suspension) |
| Difficulté d'utilisation correcte | Élevée (memory ordering) | Modérée |
| Composabilité | Faible (une opération à la fois) | Forte (section critique arbitraire) |
Règle simple : si votre section critique se réduit à une seule opération sur un type primitif (incrémenter, comparer-et-échanger, lire un flag), utilisez std::atomic. Dès qu'il faut protéger plusieurs opérations liées ou des structures complexes, passez au mutex.
Le piège classique est de tenter de protéger une opération multi-étapes avec des atomiques individuels. Chaque opération atomique est indivisible, mais la séquence ne l'est pas :
std::atomic<int> balance{1000};
void withdraw(int amount) {
// ❌ Race condition : un autre thread peut modifier balance
// entre le load et le store
if (balance.load() >= amount) { // Étape 1 : vérification
balance.fetch_sub(amount); // Étape 2 : retrait
}
// Un autre thread peut retirer entre les étapes 1 et 2,
// rendant le solde négatif.
// ✅ Version correcte avec boucle CAS
int current = balance.load();
while (current >= amount) {
if (balance.compare_exchange_weak(current, current - amount)) {
return; // Succès atomique
}
// current mis à jour automatiquement — on réessaie
}
// Solde insuffisant
}| Aspect | Détail |
|---|---|
| Header | <atomic> |
| Types supportés | Types trivialement copiables (entiers, flottants C++20, pointeurs, petites structs) |
| Opérations de base | load(), store(), exchange() |
| Read-modify-write | fetch_add(), fetch_sub(), fetch_and(), fetch_or(), fetch_xor() |
| Compare-and-swap | compare_exchange_weak(), compare_exchange_strong() |
| Ordering par défaut | memory_order_seq_cst (le plus sûr) |
| Lock-free garanti | Seulement std::atomic_flag ; les autres dépendent de la taille et de l'architecture |
| Vérification lock-free | is_always_lock_free (compile-time), is_lock_free() (runtime) |
| C++20 | std::atomic_ref, atomic<float/double>, wait()/notify_one()/notify_all() sur tous les atomiques |
| Règle d'or | Utiliser seq_cst par défaut ; n'affaiblir que sur preuve de besoin |
À suivre : la section 21.5 présente
std::asyncetstd::future, qui offrent une abstraction de plus haut niveau pour la programmation asynchrone — lancer un calcul en arrière-plan et récupérer son résultat (ou son exception) proprement.