74 / 163 · C++11 · 10 min
Atomic operations: data races, RMW, and lock-free limits
atomic makes accesses to a single atomic object indivisible; it does not turn a multi-step business operation into a transaction. Counter updates need a read-modify-write such as fetch_add. An atomic type also does not promise to be lock-free, and it does not make every algorithm built on it wait-free.
In this lesson
A data race is not an occasional wrong answer
When different threads perform conflicting accesses to the same memory location, at least one access is not atomic, and no happens-before relation orders them, the result may be a data race and undefined behavior. Conflict includes one read and one write, and it also includes two writes. Writing the same value does not make the accesses safe. Concurrent increment of an ordinary counter is not an acceptable form of approximate statistics.
Replacing the counter with std::atomic<int> and touching it only through the atomic interface avoids a data race on that object. A race condition in the business sense is broader. A program can be free of data races and still violate its logic because another thread slips between a check and an action. Judging thread safety requires both memory legality and the business invariant.
A read-modify-write must be a single atomic operation
load only reads. store only writes. fetch_add is an indivisible read-modify-write and returns the value before the modification. counter.store(counter.load() + 1) is two operations. Both threads may load zero and then each store one: the accesses are race-free and still lose an increment. fetch_add(1) or an atomic increment is what expresses the whole update you need.
The example lets the worker and the main thread each add one thousand times, then reads two thousand after join. The counter does not publish other data, so the updates may use relaxed ordering. join supplies the synchronization edge that the task has finished. Conditional updates can use compare-exchange: the new value is committed only when the comparison succeeds, and on failure expected is updated to the observed value. The weak form may fail spuriously and usually lives in a retry loop. The loop should recompute any target that depends on the old value.
Atomicity, lock-freedom, and speed are three different things
The standard allows many atomic<T> implementations to use a lock internally. is_lock_free() asks whether operations on that object are lock-free. C++17 is_always_lock_free is a compile-time guarantee on the type. You cannot infer either from the name or from the object size. Atomic operations on atomic_flag are lock-free, but a spinlock built around it can still wait for another thread to release the flag.
Lock-free also does not mean every thread finishes in a bounded number of steps, and it does not mean fast. High contention on the same cache line has a communication cost. Making several fields separately atomic does not yield a consistent snapshot. For a complex shared structure, prefer a mutex that protects the whole state, and design an atomic protocol only when you need one. volatile does not provide inter-thread atomicity or a synchronization relation. It is not a substitute for these tools.
Pitfalls
- Even the default
seq_cstorder cannot merge a separateloadandstoreinto one atomic increment; a stronger memory order does not fix the granularity of the operations. - An atomic pointer protects only the pointer value. It does not automatically protect reads and writes of the pointee, and it does not stop another thread from destroying that object too early.
Run an example
Minimum C++11 · complete program · Download .cpp
#include <atomic>
#include <cassert>
#include <iostream>
#include <thread>
int main() {
std::atomic<int> count{0};
const auto increment = [&] {
for (int i = 0; i < 1000; ++i) {
count.fetch_add(1, std::memory_order_relaxed);
}
};
std::thread worker(increment);
increment();
worker.join();
const int total = count.load(std::memory_order_relaxed);
assert(total == 2000);
const int old = count.fetch_add(5, std::memory_order_relaxed);
assert(old == 2000);
assert(count.load(std::memory_order_relaxed) == 2005);
std::cout << total << ' ' << old + 5 << '\n';
}
Compile locally
g++ -std=c++11 -Wall -Wextra -Wpedantic -pthread concurrency-atomics.cpp -o example && ./exampleExpected result
2000 2005
CHECK YOUR UNDERSTANDING
Close the answer. Explain it.
Two threads take the last ticket with if (tickets.load() > 0) tickets.fetch_sub(1). If every operation is atomic, is the ticket count guaranteed not to go negative? How should it be fixed?
Show a reference answer
No. Both threads may first observe one and then each subtract, producing zero and negative one. Merge the test with the decrement: set expected = tickets.load(); loop while expected > 0; try compare_exchange_weak(expected, expected - 1); on success the ticket is taken and the loop exits; on failure recheck with the updated expected. If the count does not publish other state, relaxed ordering is enough. A mutex that protects both the check and the decrement also works.
Check the sources
- cppreference: std::atomic operation categories and specializations
- C++ working draft: [intro.races] conflicting accesses and data races
Drafts and official chapters change. The version mark is only the example’s minimum.