Course outline

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.

About 5 min readContent reviewed
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.

c Observe an atomic read-modify-write
#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;
}
Expected output
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.

c A shared counter with completed workers
#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;
}
Expected output
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.

Official references