Concurrency[conc]
std::atomic in C++: what it guarantees and when to use it
- Section
- Concurrency [conc]
- Standard
- C++20
- Compilers
- MSVC 19.44, Clang 19.1 (MSVC STL), GCC 16.2, Clang 23.1 (libc++)
- Published
- Length
- 9 min read
In this answer10 clauses
- 1What std::atomic guarantees, and what it does not
- 2std::atomic operations and what they return
- 3compare_exchange_weak: change it only if it is still what you read
- 4A stop flag:
std::atomic<bool>,std::atomic_flag, not volatile - 5Which types std::atomic takes, and which are lock-free
- 6Memory orders, and why to keep the default
- 7What C++20, C++23 and C++26 added
- 8False sharing and hardware_destructive_interference_size
- 9std::atomic or a mutex
- 10A checklist for std::atomic
Abstract
std::atomic<T> makes each single operation on its value, such as load(), store(), fetch_add() or compare_exchange_weak(), indivisible and free of data races. Two operations in a row are not one: x = x + 1 on an atomic is a load and then a store, and another thread can act between them. Write such an update as fetch_add() or a compare_exchange_weak() loop, keep the default memory order, and use a mutex when several values must change together.

std::atomic<T> makes each single operation on its value indivisible: a load(), store(), ++, fetch_add(), exchange() or compare_exchange_weak() happens whole, and threads calling them at once have no data race. Two operations in a row are not one: x = x + 1 is a load and then a store, and another thread can act between them. Write an update that depends on the old value as fetch_add() or a compare_exchange_weak() loop, keep the default memory order, and use a mutex when several values must change together.
This answer is written for C++20. std::atomic is C++11 (Since When’s C++11 table gives its first releases); C++20 added wait(), std::atomic_ref and the rest of the additions table below, C++23 made C’s <stdatomic.h> usable from C++, and C++26 adds fetch_max() and fetch_min().
The samples were built with MSVC 19.44, Clang 19.1 (MSVC STL), GCC 16.2 and Clang 23.1 (libc++): the first two on Windows x64 with Microsoft’s standard library, the other two on Compiler Explorer, Linux x86-64. Every program here prints the same thing on every run (each threaded one printed one output in 30 runs with MSVC and Clang 19.1), so the lost update below is written out without threads.
What changed: a thread waits for an atomic flag by polling it in a loop calling flag.wait(false), woken by notify_one().
since C++20
What std::atomic guarantees, and what it does not
Two threads add 100,000 sensor readings each to one counter (starting and joining them is in std::thread and std::jthread):
std::atomic<long> readings_seen{0};
void count_readings(int times) {
for (int i = 0; i < times; ++i)
readings_seen.fetch_add(1); // one indivisible operation
}
void count_more_readings(int times) {
for (int i = 0; i < times; ++i)
++readings_seen; // the same operation, written as an operator
}
int main() {
std::thread first(count_readings, 100000);
std::thread second(count_more_readings, 100000);
first.join();
second.join();
std::cout << "readings seen: " << readings_seen.load() << '\n';
}— end example]
It printed readings seen: 200000 on all four. fetch_add() and ++ are each one read-modify-write, with no gap between the read and the write for another thread to use. A load followed by a store has that gap. This program writes out, in one thread, an order that two threads running readings_seen = readings_seen + 1 may take:
std::atomic<long> readings_seen{0};
int main() {
// Two threads each run readings_seen = readings_seen + 1: a load,
// then a store. One order the scheduler may pick, written out in
// one thread, with no threads at all:
long read_by_first = readings_seen.load(); // first reads 0
long read_by_second = readings_seen.load(); // second reads 0
readings_seen.store(read_by_first + 1); // first stores 1
readings_seen.store(read_by_second + 1); // second stores 1
std::cout << "two load-then-store updates: " << readings_seen.load()
<< '\n';
readings_seen.store(0);
readings_seen.fetch_add(1); // one operation: no gap between
readings_seen.fetch_add(1); // its read and its write
std::cout << "two fetch_adds: " << readings_seen.load() << '\n';
}— end example]
It printed:
two load-then-store updates: 1
two fetch_adds: 2— end example]
Figure 2 draws the same interleaving from a counter at 5: both threads load 5, both store 6, and one increment is lost. With two fetch_add(1) calls it ends at 7.

Nothing flags the mistake. These three lines compiled with no warning on all four, at /W4 and -Wall -Wextra:
readings_seen = readings_seen + 1; // a load, then a store: two
readings_seen += 1; // one read-modify-write
++readings_seen; // one read-modify-write— end example]
std::atomic operations and what they return
The returns are what all four printed (cppreference’s std::atomic lists the members of each specialization):
| Operation | On | Returns |
|---|---|---|
load() |
every T |
the value |
store(v) |
every T |
nothing |
exchange(v) |
every T |
the old value |
compare_exchange_weak(e, v), compare_exchange_strong(e, |
every T |
true if it stored v; false after writing the current value into e |
fetch_add(v), fetch_sub(v) |
integers, pointers, floating point (C++20) | the old value |
++x, --x |
integers, pointers | the new value |
x++, x-- |
integers, pointers | the old value |
x += v, x -= v |
integers, pointers, floating point (C++20) | the new value |
fetch_and(v), fetch_or(v), fetch_xor(v) |
integers | the old value |
x &= v, x |= v, x ^= v |
integers | the new value |
An atomic cannot be copied or moved: std:: was an error on all four, so take the value with load().
compare_exchange_weak: change it only if it is still what you read
To set a value according to what it was, read it into expected, compute the new value, and call compare_exchange_weak(expected,. It stores desired and returns true only if the atomic still holds expected; otherwise it writes the current value into expected and returns false, and the loop computes again. Here record() keeps highest_reading, the highest reading any thread has seen:
std::atomic<int> highest_reading{0};
void record(int reading) {
int expected = highest_reading.load();
// Store ours only while it is still higher than what is there
while (expected < reading &&
!highest_reading.compare_exchange_weak(expected, reading)) {
// failed: expected now holds the value another thread stored
}
}— end example]
Two threads call record(), one climbing from 0 to 50000 and one falling from 60000; it printed highest reading: 60000 on all four. The loop has no second load(): the failed call has already put the current value in expected, so in Figure 3 the false arrow returns to computing, not to reading.

compare_exchange_weak may fail spuriously, returning false though the value matched; in a loop that costs one more pass. Where there is no loop, use compare_exchange_strong: the standard’s note says that when a weak exchange would need a loop and a strong one would not, the strong one is preferable ([atomics.types.operations]).
Both compare the bytes of the value, its value representation, not operator==:
std::atomic<double> offset{0.0};
double expected = -0.0; // equal to 0.0 by ==, but other bits
std::cout << std::boolalpha;
std::cout << "0.0 == -0.0: " << (0.0 == expected) << '\n';
bool stored = offset.compare_exchange_strong(expected, 1.5);
std::cout << "stored: " << stored << '\n';
std::cout << "expected is still -0.0: " << std::signbit(expected)
<< '\n';— end example]
All four printed:
0.0 == -0.0: true
stored: false
expected is still -0.0: false— end example]
The exchange failed though == said equal, and left positive zero in expected. Padding bits in a struct take no part since C++20 (P0528R3: libstdc++ 13.1, Visual Studio 2019 16.7, libc++ only from release 24, so not the 23.1 used here).
C++26 puts this loop in the library as highest_reading., beside fetch_min(); only GCC 16.2 built it, printing the same 60000.
A stop flag: std::atomic<bool>, std::atomic_flag, not volatile
A flag that one thread sets and another polls is a std::atomic<bool>. Here main sets stop_requested once the worker has started, an order a std::latch fixes:
std::latch worker_started(1); // fixes the order of the output
std::atomic<bool> stop_requested{false};
void sensor_loop() {
std::cout << "worker: started\n";
worker_started.count_down();
while (!stop_requested.load()) {
// read a sensor, publish the reading...
}
std::cout << "worker: saw the stop flag\n";
}
int main() {
std::thread worker(sensor_loop);
worker_started.wait();
std::cout << "main: requesting stop\n";
stop_requested.store(true);
worker.join();
std::cout << "main: worker joined\n";
}— end example]
All four printed the lines in that order. A volatile bool would not do: a volatile access is not atomic and does not synchronize threads, so one thread writing a volatile bool while another reads it is a data race, which is undefined behavior ([intro.races]; CP.8: “Don’t try to use volatile for synchronization”). A std::jthread comes with a ready-made stop flag, its std::stop_token and request_stop().
Since C++20 a waiting thread need not poll. wait(old) blocks while the value equals old, and a store() followed by notify_one() or notify_all() wakes it:
std::atomic<bool> data_ready{false};
int latest_reading = 0; // a plain int, published through data_ready
void display() {
data_ready.wait(false); // C++20: sleeps while the value is false
std::cout << "display: reading " << latest_reading << '\n';
}
int main() {
std::thread display_thread(display);
latest_reading = 73;
data_ready.store(true);
data_ready.notify_one(); // C++20: wakes the thread that waits
display_thread.join();
}— end example]
It printed display: reading 73 on all four. latest_reading may be read once wait() returns: it was written before the store that wait() saw, and the default memory order makes that write visible. wait() does not return spuriously either: woken with the value still old, it blocks again, and returns only once the value differs ([atomics.types.operations]).
std::atomic_flag is the bare flag: test_and_set() sets it and returns the old value, clear() clears it, and since C++20 it has test(), wait() and the notify functions, and a default-constructed one starts clear. Its operations are guaranteed lock-free, as are those of the C++20 aliases std:: and std::.
Which types std::atomic takes, and which are lock-free
T must be trivially copyable, so a type with its own copy constructor or destructor, such as std::string, cannot be atomic (std::shared_ptr has a C++20 specialization of its own, below):
std::atomic<std::string> last_message; // error— end example]
GCC’s line names the rule, and the other three assert the same:
error: static assertion failed: std::atomic requires a trivially copyable type— end example]
A struct of plain members is trivially copyable: a std::atomic<Stats> of the hub’s count, sum and highest reading compiled on all four (libc++ needed -latomic to link it, below), updated by the same compare-exchange loop. is_always_lock_free tells you at compile time, is_lock_free() at run time, and here the two agreed. sizeof(std:: in bytes, Microsoft’s STL on Windows x64, the other two on Linux x86-64:
T |
Microsoft’s STL | libstdc++ | libc++ |
|---|---|---|---|
int, 4 bytes |
4, lock-free | 4, lock-free | 4, lock-free |
long long, int* or two ints, 8 bytes |
8, lock-free | 8, lock-free | 8, lock-free |
two long longs, 16 bytes |
24, not lock-free | 16, not lock-free | 16, not lock-free |
Stats, three long longs, 24 bytes |
32, not lock-free | 24, not lock-free | 24, not lock-free |
Microsoft’s STL counts only sizes 1, 2, 4 and 8 as lock-free and keeps a lock inside a larger atomic, hence its larger sizes. libstdc++ and libc++ keep the size of T and call out to libatomic: where GCC has no instruction sequence for an operation, it emits a call to an external routine (GCC’s __atomic builtins). With -mcx16, Clang 23.1 made the 16-byte struct lock-free; GCC 16.2 did not.
That call needs libatomic at link time. GCC 16.2 added it by itself (GCC 16’s -flink-libatomic, on by default where the target supports it). Clang 23.1 with libc++ did not: the Stats program failed with undefined reference to symbol '__atomic_load@@LIBATOMIC_1. until -latomic was added. Microsoft’s STL needs nothing, since the lock is in the object.
Memory orders, and why to keep the default
The functions that read or write the value (load(), store(), exchange(), the compare_exchange and fetch_ functions, wait()) take an optional std::memory_order, seq_cst by default; the operators (=, ++, += and the rest) take none and always use seq_cst (cppreference’s memory_order). What each order promises:
| Order | Used on | Promises |
|---|---|---|
seq_cst, the default |
any operation | what acquire and release give, plus one order of all seq_cst operations that every thread agrees on |
acquire |
a load or a read-modify-write | if it reads the value a release stored, that store synchronizes with it: what was written before the store is visible after it |
release |
a store or a read-modify-write | synchronizes with an acquire that reads the value it stored |
acq_rel |
a read-modify-write | both |
relaxed |
any operation | atomicity only: no ordering of other memory |
No sample can make a weaker order fail on demand, and none here pretends to. This one shows the syntax only; it printed display: reading 73, readings seen: 100000 and highest reading: 999 on all four:
std::atomic<long> readings_seen{0};
std::atomic<int> highest_reading{0};
std::atomic<bool> data_ready{false};
int latest_reading = 0; // a plain int, published through data_ready
void record(int reading) {
readings_seen.fetch_add(1, std::memory_order::relaxed); // a count
int expected = highest_reading.load(std::memory_order::relaxed);
while (expected < reading &&
!highest_reading.compare_exchange_weak(
expected, reading,
std::memory_order::acq_rel, // if it stores
std::memory_order::relaxed)) { // if it fails
}
}
void sensor() {
for (int r = 0; r < 100000; ++r) record(r % 1000);
latest_reading = 73;
data_ready.store(true, std::memory_order::release);
data_ready.notify_one();
}
void display() {
data_ready.wait(false, std::memory_order::acquire);
std::cout << "display: reading " << latest_reading << '\n'; // 73
}— end example]
A compare-exchange takes two orders, one for when it stores and one for when it fails, and the failure order must be relaxed, acquire or seq_cst. std:: is the C++20 spelling, which all four rejected as C++17; std:: is the C++11 one.
Keep the default unless a profile shows its cost and you can prove the weaker order correct. CP.100’s exception says it: “Atomic variables can be used simply and safely, as long as you are using the sequentially consistent memory model (memory_order_seq_cst), which is the default”; CP.101 adds “Distrust your hardware/compiler combination”. C++26 deprecates memory_order:: (P3475R2), moving it to Annex D with the same meaning as acquire ([depr.atomics.order]), so it still compiles: all four built it as C++26 without a warning.
What C++20, C++23 and C++26 added
Each addition by library, as built:
| Addition | Since | Microsoft’s STL 14.44 | libstdc++ 16.2 | libc++ 23.1 |
|---|---|---|---|---|
| Default constructor value-initializes | C++20 | yes, in C++17 mode too | yes | yes |
wait() and the notify functions |
C++20 | yes | yes | yes |
atomic_flag::test() |
C++20 | yes | yes | yes |
std::atomic_ref<T> |
C++20 | yes | yes | yes |
Floating-point fetch_add() |
C++20 | yes | yes | yes |
std:: |
C++20 | yes | yes | no, an error |
<stdatomic.h> and _Atomic(T) |
C++23 | yes | yes | yes |
fetch_max() |
C++26 | no | yes | no |
Before C++20 the standard’s default constructor left the atomic “in an uninitialized state”, and libstdc++ and libc++ still do in C++17 mode: a local atomic declared without a value holds an indeterminate value there, while one at namespace scope is zero-initialized like any static object. In code that must also build as C++17, give it its value: std::.
std::atomic_ref<int> makes the operations on a plain int atomic. While any atomic_ref to an object exists, touch the object only through atomic_refs. std:: swaps a shared configuration whole; it was not lock-free on Microsoft’s STL or libstdc++.
For code shared with C, C++23 makes <stdatomic.h> work in C++, where _Atomic(T) is std::atomic<T>:
#include <stdatomic.h> // C++23: the C header, in C++ too
// ...
_Atomic(long) readings_seen = 0; // C++23: std::atomic<long>
static_assert(std::is_same_v<_Atomic(long), std::atomic<long>>);
int main() {
atomic_fetch_add(&readings_seen, 1); // C's spelling...
readings_seen.fetch_add(1); // ...and C++'s, on one object
std::cout << "readings seen: " << atomic_load(&readings_seen)
<< '\n';
}— end example]
All four printed readings seen: 2 as C++23 and rejected it as C++20. Only the form with parentheses is std::atomic<T>: C’s other spelling, _Atomic long readings_seen = 0;, built on none of the four. What else the two languages share is in C++ vs C.
False sharing and hardware_destructive_interference_size
Two atomics in one cache line can slow each other down when different threads write them, though no data is shared: that is false sharing. std:: (C++17, <new>) is “the minimum recommended offset between two concurrently-accessed objects” to avoid it ([hardware.interference]), and alignas applies it:
struct SensorCounts {
std::atomic<int> north{0}; // written by the north thread
std::atomic<int> south{0}; // written by the south thread
};
#ifdef __cpp_lib_hardware_interference_size
constexpr std::size_t apart =
std::hardware_destructive_interference_size;
#else
constexpr std::size_t apart = 64; // the library gives no value
#endif
struct SpacedSensorCounts {
alignas(apart) std::atomic<int> north{0};
alignas(apart) std::atomic<int> south{0};
};— end example]
All four printed 64 for the constant, 8 for sizeof(SensorCounts) and 128 for sizeof(Spaced. Nothing was timed: the constant gives the spacing, and a profile says whether two counters need it.
GCC 16.2 warned when a header used the constant, though not when the source file used it directly:
warning: use of 'std::hardware_destructive_interference_size' [-Winterference-size]
note: its value can vary between compiler versions or with different '-mtune' or '-mcpu' flags
note: if this use is part of a public ABI, change it to instead use a constant variable you define— end example]
std::atomic or a mutex
One value needs only an atomic: a counter, a flag, a maximum, including a check followed by an update of that value, which is the compare-exchange loop above. Several values that must agree, or a step that cannot be retried the way that loop retries, need a mutex, as in std::mutex and which lock to use.
A struct in one atomic keeps its fields together too, but above 8 bytes it was lock-free on none of the three libraries by default, and an operation that is not lock-free may block, as a lock does ([atomics.lockfree]). A queue or stack built from compare-exchange loops is lock-free programming, with traps such as the ABA problem: CP.100 says not to write it unless you absolutely have to, and CP.102 says to study the literature first.
A checklist for std::atomic
- One value per atomic; values that must agree go under a
std::mutex. - One operation per update:
fetch_add(),++,+=or acompare_exchange_weak()loop, neverx = x + 1. compare_exchange_strong()only where there is no loop.- The default memory order, unless a profile and a proof say otherwise.
is_always_lock_freechecked where lock-freedom matters, and-latomicwhere the link asks for it.wait()andnotify_one()in place of a polling loop (C++20).- An explicit initial value in code that must also build as C++17.
- ThreadSanitizer, from the Toolchain page, for the data that is not atomic.
More on threads and shared data: Concurrency.
Questions
Is std::atomic thread safe?
Each operation on it is: threads may call load(), store(), fetch_add() or compare_exchange_weak() on one std::atomic at the same time without a data race, and each call is indivisible. A sequence of calls is not one operation: x = x + 1 is a load and then a store, and another thread can store between them. That line compiled with no warning on MSVC 19.44, GCC 16.2 and Clang, at /W4 and -Wall -Wextra.
When should I use std::atomic instead of a mutex?
For one value: a counter, a flag, a running maximum, including a check followed by an update of that one value, which a compare_exchange_weak() loop does. When several values must agree, such as a count and a sum, or when the step after the check cannot be retried the way that loop retries, use a std::mutex. Lock-free structures built from compare-exchange loops are another matter: the C++ Core Guidelines say not to write lock-free code unless you absolutely have to (CP.100).
What is the difference between std::atomic<bool> and std::atomic_flag, and why not volatile bool?
std::atomic<bool> has load() and store() and is what a stop flag should be. std::atomic_flag is the bare flag, with test_and_set() and clear(), plus test(), wait() and the notify functions since C++20, and its operations are guaranteed lock-free. volatile bool is neither: a volatile access is not atomic and does not synchronize threads, so a volatile bool written by one thread and read by another is a data race (undefined behavior), and the Core Guidelines say not to use volatile for synchronization (CP.8).
What is the difference between compare_exchange_weak and compare_exchange_strong?
compare_exchange_weak may fail spuriously, returning false even though the atomic held the expected value, so it belongs in a loop that tries again. compare_exchange_strong fails only when the value differs. The standard’s note says that when a weak exchange would need a loop and a strong one would not, the strong one is preferable. Both write the current value into expected when they return false.
Which memory_order should I use with std::atomic?
The default, std::, which every operation uses when you name no order: all threads agree on one order of all seq_cst operations. Pass a weaker order only when a profile shows what the default costs and you can prove the weaker order correct. The Core Guidelines advise against lock-free programming you do not need (CP.100) and against trusting your hardware and compiler combination (CP.101).
Is std::atomic lock-free?
For small types on the usual platforms: int, long long, a pointer and an 8-byte struct were lock-free on Microsoft’s STL, libstdc++ and libc++ on x86-64. A 16- or 24-byte struct was not: Microsoft’s STL keeps a lock inside the atomic, making it bigger than T, while libstdc++ and libc++ call into libatomic, which Clang 23.1 with libc++ on Linux needed -latomic to link. is_always_lock_free answers at compile time, is_lock_free() at run time.
Sources
- [atomics.types.generic] Class template atomicC++ standard working draft
- [atomics.types.operations] Operations on atomic typesC++ standard working draft
- [atomics.order] Order and consistencyC++ standard working draft
- [atomics.lockfree] Lock-free propertyC++ standard working draft
- std::atomiccppreference.com
- std::memory_ordercppreference.com
- CP: Concurrency and parallelismC++ Core Guidelines
- Built-in Functions for Memory Model Aware Atomic OperationsGCC documentation