C / Advanced: Language Boundaries and Systems / Lesson 16
C11 Concurrency and Atomics
Reason about shared objects through synchronization and happens-before relationships rather than timing, volatile, or observed machine behavior.
On this page
Lesson goals
- Identify conflicting accesses and data races.
- Choose mutex protection or an atomic operation.
- Explain relaxed counting and release/acquire publication limits.
Check facilities and define sharing
C11 introduced threads.h and stdatomic.h. C17 implementations may omit these optional facilities and advertise __STDC_NO_THREADS__ or __STDC_NO_ATOMICS__. Check the implementation and required link options before using the complete examples, which assume both relevant facilities are available. On many Unix toolchains, -pthread selects the necessary thread configuration; that flag is not ISO C syntax.
A data race involves conflicting actions in different threads, at least one non-atomic, without the required happens-before ordering. A C data race causes undefined behavior. It is not merely a promise that a counter may lose an update. Define which thread owns each object or which synchronization protects every shared access.
Use one operation for one update
An atomic object provides operations that cannot be torn in the way an ordinary conflicting access can. A read followed by a separate write is still not the same as an atomic read-modify-write. atomic_fetch_add performs the increment as one atomic operation and returns the value before that increment.
The first program isolates these semantics in one thread. Relaxed ordering is sufficient for a standalone numerical counter when no other data is published through it. Atomic types are not universally lock-free; query the implementation when that property matters, and do not confuse lock-free operation with a guarantee of bounded completion for every thread.
#include <stdatomic.h>
#include <stdio.h>
int main(void)
{
atomic_int counter;
atomic_init(&counter, 5);
int previous = atomic_fetch_add_explicit(&counter, 2, memory_order_relaxed);
int current = atomic_load_explicit(&counter, memory_order_relaxed);
printf("before=%d after=%d\n", previous, current);
return 0;
}
before=5 after=7
The single fetch-add returns five and stores seven; no publication ordering is needed.
Join before relying on completion
The concurrent example starts one worker while main performs its own thousand increments. Every access to counter is atomic, and the total of two thousand fits even the minimum required int range. The shared object has static storage duration, so neither thread borrows an expired local context.
thrd_create can fail, and thrd_join must be checked before relying on successful completion. The final load occurs after a successful join, so both loops have completed. Scheduling may interleave the increments in many ways, but the successful-path total remains two thousand. This is a justified outcome of the contract, not a timing prediction.
#include <stdatomic.h>
#include <stdio.h>
#include <threads.h>
static atomic_int counter;
static int worker(void *unused)
{
(void)unused;
for (int i = 0; i < 1000; ++i)
atomic_fetch_add_explicit(&counter, 1, memory_order_relaxed);
return 0;
}
int main(void)
{
thrd_t thread;
int result;
atomic_init(&counter, 0);
if (thrd_create(&thread, worker, NULL) != thrd_success) return 1;
for (int i = 0; i < 1000; ++i)
atomic_fetch_add_explicit(&counter, 1, memory_order_relaxed);
if (thrd_join(thread, &result) != thrd_success || result != 0) return 1;
printf("counter=%d\n", atomic_load_explicit(&counter, memory_order_relaxed));
return 0;
}
counter=2000
On a supported implementation and successful thread operations, all 2000 increments are preserved.
Synchronize the data, not just the flag
A release store to an atomic flag can publish preceding writes when an acquire load in another thread reads the appropriate released value. That establishes a synchronization relationship for those writes; a relaxed flag alone does not. Publication also needs a lifetime protocol and a rule preventing concurrent later mutation of the supposedly published data.
For several related fields, a mutex often gives a clearer invariant than several independent atomics. Protect every access under the same locking policy and check initialization and locking results. Multiple locks need a consistent acquisition order to avoid deadlock. A condition-variable wait belongs in a loop that rechecks the protected predicate after waking.
Do not replace synchronization with hope
volatile controls certain observable accesses; it does not make a shared counter atomic or establish inter-thread ordering. Sleeping for a guessed duration also establishes no synchronization. Even correct atomic code can have a higher-level race, such as checking a balance and acting later without protecting the whole transaction.
Use ThreadSanitizer where supported in a separate instrumented build to investigate executed races. Repeated successful runs do not prove race freedom. Review happens-before edges, lock ownership, object lifetime, and shutdown paths. Start with sequentially consistent atomics or a mutex when the design is unclear, then weaken ordering only with a written correctness argument.
Common errors and fixes
volatile is used to repair a shared increment.
- Why it happens
- Observable access was confused with atomic synchronization.
- How to fix it
- Use an atomic read-modify-write or a mutex around the complete update.
- Prevent it next time
- Review every shared object’s access policy.
An atomic flag becomes true but payload access is invalid.
- Why it happens
- Ordering, lifetime, or later mutation was not covered by the publication protocol.
- How to fix it
- Establish an appropriate release/acquire relationship and data-lifetime policy.
- Prevent it next time
- Document exactly which write the acquire observes.
Shutdown frees state while a worker still uses it.
- Why it happens
- The owner did not wait for borrowing threads to finish.
- How to fix it
- Signal shutdown using synchronization and join before release.
- Prevent it next time
- Include failure and cancellation paths in the lifetime design.
Check your understanding
1. Why is atomic_load followed by atomic_store of old + 1 insufficient for an exact shared counter?
Show worked solution
Two threads can load the same prior value and each store the same incremented value. The individual accesses are atomic, so this need not be a data race, but the combined algorithm loses an increment. Use atomic_fetch_add for one read-modify-write or protect the whole load-and-store transaction with a mutex. Atomicity of components does not imply atomicity of a compound operation.
2. A worker receives a pointer to a local configuration. State a safe lifetime and shutdown protocol.
Show worked solution
Keep the configuration alive and unmodified under the chosen sharing policy until the worker is finished. Check creation; if it succeeds, arrange synchronized shutdown when required and successfully join before leaving the configuration’s scope. If the worker retains nested pointers, their targets need the same guarantee. A copied top-level pointer does not extend any target lifetime.
Key takeaways
- Race freedom needs a synchronization argument, not a successful schedule.
- Relaxed atomic counting does not publish unrelated data.
- Join and ownership rules keep shared objects alive through shutdown.