Atomics
An atomic type makes one operation on one object indivisible. fetch_add reads, adds and writes without a window in between, and no mutex is involved: on this machine it is one instruction with a lock prefix.
The mutex of lesson 106 protects a region; an atomic protects a value. That is less powerful and much cheaper, and it is enough for a counter, a flag and the update loop that a lock-free algorithm is built from.
A counter without a lock
std::atomic<int> has the same syntax as an int and an increment that cannot be split. Four threads add 100000 each and the result is 400000, without a mutex anywhere in the program.
The member that does the work is fetch_add, which returns the value before the addition. counter++ and counter += 1 are defined the same way, and the plain counter = counter + 1 is not: it is a load, an add and a store, and the window is back.
The type is not a container and not a lock. One atomic operation is indivisible; two of them are not, and a rule that involves two variables still needs a mutex or a different design.
#include <atomic>
#include <iostream>
#include <thread>
#include <vector>
std::atomic<int> counter{0};
int main()
{
std::vector<std::thread> threads;
for (int i = 0; i < 4; ++i) {
threads.emplace_back([] {
for (int j = 0; j < 100000; ++j) {
counter.fetch_add(1);
}
});
}
for (auto& thread : threads) {
thread.join();
}
std::cout << counter.load() << '\n';
}
g++ -std=c++23 -Wall -Wextra -pthread counter.cpp -o counter
./counter
400000
load, store and exchange
load reads the value and store writes it, both as one operation. exchange writes a new value and returns the old one, which is the atomic form of swap with a literal.
The program stores 20, exchanges it for 30 and prints the old value, the new one and whether the type is lock-free on this build. The answer is 20, 30 and 1: the operations compile to a single instruction.
is_lock_free is a runtime question because a type can be too large for one instruction. An atomic of a big struct may be implemented with a lock inside, and the program is still correct, only slower.
#include <atomic>
#include <iostream>
int main()
{
std::atomic<int> value{10};
value.store(20);
int old = value.exchange(30);
std::cout << old << ' ' << value.load() << ' ' << value.is_lock_free() << '\n';
}
g++ -std=c++23 -Wall -Wextra operations.cpp -o operations
./operations
20 30 1
compare_exchange
compare_exchange_weak(expected, desired) writes the new value only if the object still holds the expected one. It returns true when it wrote and false when another thread got there first, and in the second case it puts the current value into expected.
The program updates a maximum from four threads. A thread reads the current best, compares it with its own value and tries the exchange; when the exchange fails, the loop retries with the value it just read.
This read-modify-write loop is the foundation of every lock-free structure, and it is why the type of expected is a plain value that the call updates. The weak form may fail even when the values match, so the loop is not optional.
#include <atomic>
#include <iostream>
#include <thread>
#include <vector>
std::atomic<int> best{0};
void offer(int value)
{
int current = best.load();
while (value > current && !best.compare_exchange_weak(current, value)) {
}
}
int main()
{
std::vector<std::thread> threads;
for (int i = 1; i <= 4; ++i) {
threads.emplace_back([i] { offer(i * 10); });
}
for (auto& thread : threads) {
thread.join();
}
std::cout << best.load() << '\n';
}
g++ -std=c++23 -Wall -Wextra -pthread cas.cpp -o cas
./cas
40
atomic_flag as a spinlock
std::atomic_flag is the one atomic type that is guaranteed lock-free, and it has exactly two operations: test_and_set and clear. A loop around test_and_set is a spinlock in five lines.
The program guards a counter with the spinlock and two threads add 50000 each. The result is 100000, and the difference from the mutex is what happens while the lock is taken: the waiting thread burns its time slice instead of sleeping.
A spinlock is the right choice only when the protected section is a few instructions and the wait is microseconds. For anything longer the mutex of the previous lesson is better, and lesson 109 explains why the two calls here pass a memory order.
#include <atomic>
#include <iostream>
#include <thread>
class spinlock
{
public:
void lock()
{
while (flag_.test_and_set(std::memory_order_acquire)) {
}
}
void unlock()
{
flag_.clear(std::memory_order_release);
}
private:
std::atomic_flag flag_{};
};
spinlock guard;
int counter = 0;
int main()
{
auto work = [] {
for (int i = 0; i < 50000; ++i) {
guard.lock();
++counter;
guard.unlock();
}
};
std::thread a{work};
std::thread b{work};
a.join();
b.join();
std::cout << counter << '\n';
}
g++ -std=c++23 -Wall -Wextra -pthread spin.cpp -o spin
./spin
100000
An atomic cannot be copied
A copy of an atomic would be a second object with its own value, and the copy itself could not be one atomic operation. The copy constructor is deleted, so a function that takes an atomic by value does not compile.
The program initialises one atomic from another and the compiler reports the deleted copy constructor. The message names the type and the deleted function, and it is the same shape as the mutex error of the previous lesson.
Pass the atomic by reference, or read it with load and pass the plain value. A function that needs to update the shared counter takes a reference to the atomic, which keeps every operation on the one object.
#include <atomic>
int main()
{
std::atomic<int> first{1};
std::atomic<int> second = first;
return second.load();
}
g++ -std=c++23 -Wall -Wextra copy.cpp -o copy
copy.cpp: In function 'int main()':
copy.cpp:6:31: error: use of deleted function 'std::atomic<int>::atomic(const std::atomic<int>&)'
6 | std::atomic<int> second = first;
| ^~~~~
In file included from copy.cpp:1:
/usr/include/c++/15.2.0/atomic:839:7: note: declared here
839 | atomic(const atomic&) = delete;
| ^~~~~~
copy.cpp:6:31: note: use '-fdiagnostics-all-candidates' to display considered candidates
6 | std::atomic<int> second = first;
| ^~~~~
Exercises
- Replace the mutex in the counter of lesson 106 with an atomic and compare the two programs.
- Add a second statistic, the sum of the offered values, and update both atomics in the same loop.
- Measure the spinlock against the mutex on a protected section of ten increments and explain the difference.
Questions
A new set of questions in a new order every time. Choose one answer for each, then check.
-
What does
fetch_add(1)return?Answer
The value before the addition
The fetch form returns the old value, which is what makes it usable for counters and for indices.
-
Why is
counter = counter + 1not atomic even when counter is an atomic?Answer
Because it is a load, an addition and a store, and another thread can write in between
Each of the two atomic operations is indivisible, but the pair is not a read-modify-write.
-
What does this program print?
#include <atomic> #include <iostream> #include <thread> std::atomic<int> counter{0}; int main() { auto work = []{ for (int i = 0; i < 100000; ++i) counter.fetch_add(1); }; std::thread a(work), b(work), c(work), d(work); a.join(); b.join(); c.join(); d.join(); std::cout << counter.load(); }Answer
400000
Each of the four threads adds 100000 with one atomic operation, and none is lost.
-
What does
compare_exchange_weakdo?Answer
It writes the new value only if the object still holds the expected one
The exchange is conditional on the current value, which is the primitive behind the retry loop.
-
What does this program print?
#include <atomic> #include <iostream> int main() { std::atomic<int> value{10}; value.store(20); int old = value.exchange(30); std::cout << old << ' ' << value.load() << ' ' << value.is_lock_free(); }Answer
20 30 1
The store leaves 20, the exchange returns 20 and leaves 30, and the type is lock-free here.
-
What is special about
std::atomic_flag?Answer
It is the one atomic type guaranteed to be lock-free on every platform
The standard guarantees lock-free operation for atomic_flag alone; everything else is a runtime question.
-
What does
is_lock_free()report?Answer
Whether the operation compiles to a single hardware instruction on this build
It is a runtime property of the implementation, and a big atomic may fall back to an internal lock.
-
Why is an atomic not copyable?
Answer
A copy would be a second object, and copying the value is not one atomic operation
Two atomic objects have separate values, and a non-atomic copy between them could not be indivisible.
-
What does this program print?
#include <atomic> #include <iostream> #include <thread> std::atomic_flag flag{}; int counter = 0; int main() { auto work = []{ for (int i = 0; i < 50000; ++i) { while (flag.test_and_set(std::memory_order_acquire)) {} ++counter; flag.clear(std::memory_order_release); } }; std::thread a(work), b(work); a.join(); b.join(); std::cout << counter; }Answer
100000
The spinlock makes each increment a critical section, so both threads count all 50000 iterations.
-
What does the
whilein the spinlock'slock()do?Answer
It retries test_and_set until the flag was clear and is now set
test_and_set returns the old value; the loop ends at the attempt that found the flag clear.
The code on this page was built with g++ 15.2.0 and run on Alpine Linux 3.24.3 (x86_64) in a Docker container. The programs whose section shows a compiler error were compiled until that error and were not run.
Article author: Arthur Isaev