Lock-Free Shared Counters in C++20: An Architecture Note on std::atomic
A single std::atomic counter with relaxed increments is often enough for concurrent updates, but lock-free guarantees, memory ordering, and platform fallbacks decide whether the design holds in production.
05 Feb 2026, 03:37 UTC

When a mutex is the wrong tool
When several threads increment the same counter, wrapping it in a std::mutex is correct but expensive: every update serializes through the lock, and under contention the cache line ping-pong becomes the bottleneck. The useful takeaway is that a single std::atomic<std::uint64_t> updated with fetch_add is usually the smallest design that satisfies the requirement—provided you verify the target actually implements it without a hidden lock and you choose memory ordering that matches how the value is consumed.
Requirements
Before selecting a design, pin down what the counter must guarantee:
- Concurrent updates without blocking. Threads must increment without waiting for a mutex or entering the kernel.
- No data races. Every read and write must be atomic; torn values are unacceptable.
- Ordering only where needed. If the counter is purely a statistic, relaxed ordering is sufficient. If the counter value guards other shared data, acquire-release semantics are required.
- Portable lock-freedom. The design must not silently degrade to an internal mutex on the target architecture.
The smallest suitable design
For a monotonic event counter, one atomic integer is enough. The following pattern uses std::memory_order_relaxed for increments because the counter does not order any other memory operation, and std::memory_order_acquire for snapshots only when the value is used to synchronize with other state.
#include <atomic>
#include <cstdint>
class EventCounter {
public:
void record_event() noexcept {
// Relaxed: atomicity is guaranteed, but no ordering with
// surrounding memory operations is implied.
count_.fetch_add(1, std::memory_order_relaxed);
}
std::uint64_t snapshot() const noexcept {
// Acquire: if the counter value is used as a guard for other
// shared data, this ensures subsequent reads observe writes
// that happened-before the matching release store.
return count_.load(std::memory_order_acquire);
}
private:
std::atomic<std::uint64_t> count_{0};
};
// Compile-time guarantee that 64-bit atomics are lock-free on this target.
static_assert(std::atomic<std::uint64_t>::is_always_lock_free,
"Target must provide lock-free 64-bit atomics");
If the snapshot is only used for logging or metrics and never gates access to other variables, std::memory_order_relaxed on the load is also correct and slightly cheaper. The distinction matters: acquire creates a happens-before edge, while relaxed does not.
Memory ordering decision table
| Operation | Ordering | What it guarantees | Choose it when |
|---|---|---|---|
fetch_add |
relaxed |
Atomic read-modify-write; no ordering | The counter is a standalone statistic |
load |
relaxed |
Atomic read; no ordering | Snapshot is for monitoring only |
load |
acquire |
Atomic read; synchronizes with a prior release |
The value guards access to other shared data |
fetch_add |
acq_rel |
Atomic RMW with both acquire and release | Producers publish data, then increment; consumers read the count, then read data |
Trust and data boundaries
The atomic variable is the only shared state in this design. That boundary has two sides:
- Writers are trusted for logical correctness. Atomicity prevents data races, but it cannot stop a thread from calling
record_event()twice for a single logical event. If untrusted code can invoke the increment, the counter can be inflated. Higher-level invariants—such as “one event equals one call”—must be enforced by the surrounding code, not bystd::atomic. - Readers only need the atomic guarantee. A reader that observes the value does not need to trust the writer beyond the promise that the read is atomic and, with
acquire, properly ordered.
If the counter is exposed across a process boundary or to a plugin, treat it as untrusted input: validate ranges before acting on the value.
Operational checks
Three checks catch the common mistakes before they reach production.
1. Compile-time lock-free assertion
std::atomic<std::uint64_t>::is_always_lock_free is a constexpr static member available since C++17. If it evaluates to false, the implementation falls back to a lock-based representation, and the static_assert fails at compile time. This is preferable to discovering the fallback through latency measurements.
2. Runtime stress test
Spawn multiple threads, have each perform a fixed number of increments, join them, and assert the final value equals the expected sum. A minimal test looks like this:
// Pseudostructure; run in a test binary, not in production hot paths.
// constexpr int kThreads = 8;
// constexpr std::uint64_t kIncrementsPerThread = 1'000'000;
//
// EventCounter counter;
// std::vector<std::thread> workers;
// for (int i = 0; i < kThreads; ++i) {
// workers.emplace_back([&] {
// for (std::uint64_t j = 0; j < kIncrementsPerThread; ++j) {
// counter.record_event();
// }
// });
// }
// for (auto& w : workers) w.join();
//
// assert(counter.snapshot() == kThreads * kIncrementsPerThread);
Run this under ThreadSanitizer to detect data races, and repeat it on the target architecture rather than only on the development host.
3. Assembly inspection
On x86-64, a lock-free fetch_add compiles to a single lock xadd instruction. On AArch64 it typically becomes ldadd. You can verify this locally with objdump -d on the compiled object file, or by pasting the function into Compiler Explorer and inspecting the output. If you see a call to pthread_mutex_lock or __atomic_* library helpers instead of a single instruction, the type is not lock-free on that target.
Failure modes
- Hidden mutex fallback. If
is_always_lock_freeisfalse, everyfetch_addmay acquire an internal lock. This reintroduces the latency and priority-inversion risks you tried to avoid, and the degradation is silent unless you check. - Incorrect memory ordering. Using
relaxedloads when the counter actually guards other data can produce stale observations. A thread might read an old count, skip initialization, and access unconstructed memory. Conversely, usingmemory_order_seq_csteverywhere is correct but imposes unnecessary full barriers on strongly ordered architectures. - Lost updates from manual RMW. A read-modify-write built from separate
loadandstoreoperations can lose updates entirely, which is whyfetch_addis preferred over composing the operation yourself. - Logical double counting. Atomicity is not idempotency. If the same event is recorded twice because of a retry loop or an at-least-once delivery guarantee, the counter will be wrong no matter how correct the memory ordering is.
- Overflow.
std::uint64_twraps modulo 2^64. If the counter approaches that limit, the design needs a wider accumulator or a saturating policy.
Conditions that would change the design
The single-atomic design is the smallest thing that works, but several requirements push it out of scope:
- Cross-counter invariants. If two counters must be updated together so that readers never observe one without the other, pack them into a single
std::atomicstruct and update both withcompare_exchangein a loop, or accept a mutex. - Types wider than 64 bits.
std::atomicfor a 128-bit struct or a custom wide type is not guaranteed to be lock-free. Split the counter into per-thread shards that are summed on read, or use a lock. - Integration into a larger lock-free structure. If the counter lives inside a lock-free queue or hash map, node lifetime management becomes the harder problem. Hazard pointers or epoch-based reclamation are then required, and the counter itself is no longer the architectural center of gravity.
- Cross-process sharing.
std::atomicin shared memory requires placement construction and careful handling of process lifecycles; a file lock or a dedicated shared-memory allocator is usually a better fit.
Limitations and a practical check
This design assumes a C++20 compiler and a target where 64-bit atomics are lock-free. GCC 13, Clang 16, and MSVC 19.40 are reasonable baseline versions, but you must confirm the property on the actual deployment architecture—is_always_lock_free is the authoritative check, not the compiler version. The practical verification is the stress test above: if the final sum matches the expected total under ThreadSanitizer and the assembly shows a single atomic instruction, the design is behaving as intended. If either check fails, treat the result as a signal to revisit the memory ordering or the lock-free assumption rather than tuning the workload.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.