🚀 Modern C (C11 → C23)
Atomics & threads: sharing memory without lying to yourself
▶ Open the interactive lesson — free, no signupYour 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.
#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;
}$ 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).
#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;
}$ 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:
#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)