C11 Atomic Counters: A Lock-Free Increment Example
Use C11 atomics to make a thread-safe increment-only counter without a mutex, plus compile flags, verification steps, and memory-order trade-offs.
03 Feb 2026, 14:31 UTC

A shared counter incremented by many threads can finish with the wrong total. The cause is a data race: two threads read the same memory location, add one, and write it back at overlapping times, so one update is silently lost. These bugs are hard to reproduce because they depend on thread scheduling.
The practical takeaway is that C11's <stdatomic.h> provides atomic_fetch_add, which performs the read-modify-write as one indivisible operation. For an increment-only counter, this often removes the need for a mutex and reduces lock contention.
Why a plain integer fails under threads
An ordinary unsigned int is not an atomic object. The expression counter++ compiles to separate load, add, and store instructions. When multiple threads interleave those instructions, the program has a data race, which is undefined behavior in C11, and the final value is usually below the expected total. A mutex fixes it, but lock acquisition and release add cost when threads increment millions of times.
The C11 atomic replacement
atomic_uint is an unsigned integer type that supports atomic operations. atomic_fetch_add_explicit adds a value and returns the previous contents as a single atomic step. The memory_order_relaxed argument tells the compiler and CPU that this operation does not need to synchronize with other memory accesses; it guarantees atomicity of the counter itself, but creates no happens-before relationship with other variables, meaning it does not order this update relative to other reads or writes.
Use a C11 compiler and verify at compile time with __STDC_VERSION__ >= 201112L. Lock-free operation is not guaranteed by the C11 standard. Inspect ATOMIC_UINT_LOCK_FREE to see whether the implementation uses hardware instructions or may fall back to a hidden lock: 2 means always lock-free, 1 means sometimes lock-free, and 0 means never lock-free.
Worked example: an increment-only counter
Run the following in a terminal on a POSIX system. No special permissions are required. Replace counter.c with your source filename if you rename it.
#include <stdio.h>
#include <stdlib.h>
#include <stdatomic.h>
#include <pthread.h>
#define THREAD_COUNT 64
#define INCREMENTS_PER_THREAD 1000000UL
static atomic_uint counter = ATOMIC_VAR_INIT(0);
typedef struct {
atomic_uint *target;
unsigned long count;
} worker_args;
static void *worker(void *arg) {
worker_args *args = arg;
for (unsigned long i = 0; i < args->count; i++) {
atomic_fetch_add_explicit(args->target, 1, memory_order_relaxed);
}
return NULL;
}
int main(void) {
pthread_t threads[THREAD_COUNT];
worker_args args = { .target = &counter, .count = INCREMENTS_PER_THREAD };
for (int i = 0; i < THREAD_COUNT; i++) {
if (pthread_create(&threads[i], NULL, worker, &args) != 0) {
perror("pthread_create");
return EXIT_FAILURE;
}
}
for (int i = 0; i < THREAD_COUNT; i++) {
pthread_join(threads[i], NULL);
}
unsigned long expected = (unsigned long)THREAD_COUNT * INCREMENTS_PER_THREAD;
unsigned long actual = atomic_load_explicit(&counter, memory_order_relaxed);
printf("expected=%lu actual=%lu\n", expected, actual);
return actual == expected ? EXIT_SUCCESS : EXIT_FAILURE;
}
Compile and run:
cc -std=c11 -pthread counter.c -o counter
./counter
If correct, the program exits with status 0 and prints expected=64000000 actual=64000000. A smaller actual value or a non-zero exit indicates a synchronization or compile problem. To check for data races, build with ThreadSanitizer and run again:
clang -fsanitize=thread -std=c11 -pthread counter.c -o counter_tsan
./counter_tsan
ThreadSanitizer is also available in GCC. A clean run reports no data races. This example is not presented as a tested benchmark; verify it on your target platform.
Trade-offs and limitations
- Relaxed ordering is narrow. It guarantees the counter updates atomically, but it does not synchronize other memory. If another thread uses the counter value to decide whether it is safe to read data written by the incrementing thread,
memory_order_relaxedis insufficient. Use acquire/release or sequential consistency in that case. - Lock-free is not promised.
ATOMIC_UINT_LOCK_FREEmay be 0, 1, or 2. A value of 0 means the object is never lock-free; the implementation may use a hidden mutex, erasing the performance advantage. - Single-word only. This pattern fits simple increment or decrement workloads. Check-then-act logic, such as “if counter is zero, initialize resource,” needs
atomic_compare_exchange_strongor a mutex. - Cache-line contention. Even without a lock, many threads hammering the same atomic variable can contend on the cache line. Measure before assuming it is faster than a mutex.
When to choose a mutex instead
Start with pthread_mutex_t if the counter coordinates access to other memory, if you need strict ordering, or if ATOMIC_UINT_LOCK_FREE reports that the type is not lock-free. Switch to atomic_fetch_add with relaxed ordering only when profiling shows lock contention and the semantics match a standalone counter.
Practical verification: compile with -std=c11, confirm __STDC_VERSION__ is at least 201112L, run with a large thread count, compare the final value to the expected product, and optionally run under ThreadSanitizer. If the value matches and no races are reported, the counter is behaving as intended for this workload.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.