The C Path — learn C, visually

🚀 Modern C (C11 → C23)

Atomics & threads: sharing memory without lying to yourself

⏱ 14 min · free interactive lesson · quizzes, visualizations & a real compiler

▶ Open the interactive lesson — free, no signup
Why you're learning this

Your phone plays music, downloads a file, and redraws the screen all at once because real programs run several threads — independent streams of work — at the same time. Let two threads each add 1 to the same counter a million times, though, and the total comes out wrong, and differently wrong, every single run. You'll watch an update vanish in slow motion, then fix the code so shared numbers — scores, balances, download counts — always come out right.

Until C11, the C standard pretended threads didn't exist — real programs used pthreads, a Unix add-on library, and hoped. C11 finally made threads official: rules for how threads may share memory, the _Atomic marker for shared variables, <stdatomic.h>, and a portable thread library in <threads.h>. To see why any of that is needed, let's watch innocent code fall apart.

The crime scene: counter++

counter++ looks like one operation. To the CPU it's three: load the value into a register, add one, store it back. Run two threads and the OS can pause either one between any of those steps. Step through a lost update:

This spot has an interactive trace widget — open the interactive lesson to play with it.

Two increments happened; the counter went up by one. Scale that to a million increments per thread and you get garbage — a different garbage every run. This is a data race, and in C it isn't just "wrong numbers": two threads accessing the same non-atomic object, at least one writing, with no synchronization, is undefined behavior, full stop.

race.c — broken on purpose
#include <stdio.h>
#include <threads.h>

#define N 1000000
long counter = 0;                 /* shared, NOT protected */

int worker(void *arg) {
    (void)arg;
    for (int i = 0; i < N; i++)
        counter++;                /* load + add + store: a data race */
    return 0;
}

int main(void) {
    thrd_t a, b;
    thrd_create(&a, worker, NULL);
    thrd_create(&b, worker, NULL);
    thrd_join(a, NULL);
    thrd_join(b, NULL);
    printf("expected %d, got %ld\n", 2 * N, counter);
    return 0;
}
terminal
$ gcc -std=c17 race.c -o race -lpthread && ./race
expected 2000000, got 1183957
$ ./race
expected 2000000, got 1427312
$ ./race
expected 2000000, got 1996004
# different every run — and since a data race is UB, even "close" runs
# prove nothing. The program is simply wrong.

🧠 Checkpoint: Why does counter++ lose updates across threads?

  • The compiler removes duplicate increments
  • It’s three steps (load, add, store) and threads can interleave between them
  • ints can’t be shared between threads at all
  • printf is not thread-safe
Show answer

It’s three steps (load, add, store) and threads can interleave between them — Each thread works on a private register copy. If both load the same old value, both store back old+1 — one increment is overwritten. The C standard classifies this unsynchronized write as a data race: undefined behavior.

The fix, part 1: _Atomic

Declare the shared variable _Atomic and every load, store, and read-modify-write on it becomes indivisible — the hardware does the whole load-add-store as one uninterruptible operation (think x86 lock add). <stdatomic.h> provides convenience typedefs (atomic_int, atomic_long, atomic_bool…) and explicit functions (atomic_load, atomic_store, atomic_fetch_add, atomic_compare_exchange_strong).

fixed.c — same program, atomic counter
#include <stdio.h>
#include <threads.h>
#include <stdatomic.h>

#define N 1000000
atomic_long counter = 0;          /* == _Atomic long */

int worker(void *arg) {
    (void)arg;
    for (int i = 0; i < N; i++)
        counter++;                /* now ONE indivisible hardware op */
    return 0;
}

int main(void) {
    thrd_t a, b;
    thrd_create(&a, worker, NULL);
    thrd_create(&b, worker, NULL);
    thrd_join(a, NULL);
    thrd_join(b, NULL);
    printf("expected %d, got %ld\n", 2 * N, (long)counter);
    return 0;
}
terminal
$ gcc -std=c17 fixed.c -o fixed -lpthread && ./fixed
expected 2000000, got 2000000
$ ./fixed
expected 2000000, got 2000000
# correct every single time — at the cost of slower, serialized increments
💀

Atomics protect operations, not logic. if (atomic_load(&n) > 0) { atomic_fetch_sub(&n, 1); } is still racy — another thread can jump in between the check and the subtract. Each atomic op is indivisible; a sequence of them is not. For multi-step invariants, you want a mutex.

🧠 Checkpoint: Thread A runs if (atomic_load(&n) > 0) atomic_fetch_sub(&n, 1);. Safe?

  • Yes — every operation is atomic
  • No — another thread can act between the load and the sub
  • Only if n is also volatile
  • Only on x86
Show answer

No — another thread can act between the load and the sub — Each call is individually atomic, but the check-then-act SEQUENCE is not: n can hit 0 between the two calls and you subtract into negative territory. Fix with a mutex, or a compare-exchange loop (atomic_compare_exchange_strong).

The fix, part 2: mutexes and <threads.h>

C11's thread API is small and readable: thrd_create / thrd_join for threads (a thread function is int f(void *arg)), mtx_t with mtx_lock / mtx_unlock for mutual exclusion, and cnd_t condition variables for "sleep until someone signals". A mutex makes a whole region exclusive:

mutex.c (core pattern)
#include <threads.h>

mtx_t lock;                        /* mtx_init(&lock, mtx_plain); */
long items = 0, total_weight = 0;  /* must change TOGETHER        */

void add_item(long w) {
    mtx_lock(&lock);       /* one thread at a time from here...   */
    items += 1;
    total_weight += w;     /* invariant: total matches item count */
    mtx_unlock(&lock);     /* ...to here. Others block on lock.   */
}

/* cnd_t (condition variables) complete the toolkit: a consumer can
   cnd_wait(&nonempty, &lock) — sleep, releasing the lock — until a
   producer calls cnd_signal(&nonempty). */

Rule of thumb: use an atomic for a single hot counter or flag; use a mutex the moment two or more values must change together (push an item and bump a count). Atomics are faster; mutexes protect invariants.

🧠 Checkpoint: When do you need a mutex rather than atomics?

  • Whenever more than 2 threads exist
  • When several variables must be updated as one consistent unit
  • Never — atomics fully replace mutexes
  • Only when using pthreads instead of threads.h
Show answer

When several variables must be updated as one consistent unit — Atomics make single operations on single objects indivisible. The moment your invariant spans multiple values (or multiple steps), you need mutual exclusion around the whole region — that’s exactly what mtx_lock/mtx_unlock provide.

Two honest footnotes

Memory ordering exists. Every atomic op above uses the default, memory_order_seq_cst — the strongest and safest ordering. The standard also offers weaker orderings (relaxed, acquire, release…) that let experts trade guarantees about when other threads see your writes for speed. It is a genuinely deep topic; until you've read up on it properly, staying with the sequentially-consistent defaults is not a cop-out — it's engineering.

pthreads vs C11 threads. In the real world you'll mostly see POSIX pthread_create & co. — older, richer (read-write locks, barriers, attributes), and universal on Unix. <threads.h> is a thin portable wrapper over the same machinery (glibc ships it since 2.28; it's even optional — check __STDC_NO_THREADS__). The concepts transfer one-to-one, so learn either and you've learned both.

This spot has an interactive editor widget — open the interactive lesson to play with it.

From the hardest feature in modern C to one of the simplest — next, a one-word promise to the compiler: this function never returns.

▶ Practice this lesson interactively (with live gcc)