pthreads Fundamentals¶
A thread is a schedulable execution context within a process. Multiple threads in a process share the address space, file descriptors, signal handlers, and the current working directory — they differ in their stack, registers, errno, thread-local storage, and priority. That sharing is the point and the danger: two threads reading and writing the same variable without coordination is undefined behavior, full stop.
This file gives you the core pthreads API and the three synchronization primitives you’ll actually reach for daily: mutexes, condition variables, and read/write locks. You’ll internalize one non-negotiable pattern — the wait-loop-while-condition idiom for condition variables — and understand why spurious wakeups exist and why you must code defensively around them.
Note on C11 threads vs pthreads¶
C11 added <threads.h> with thrd_create, mtx_lock, cnd_wait, etc. — a slimmer API that mirrors pthreads. Reddit r/C_Programming sentiment in 2025: “C11 threads API is far more ergonomic and simple; can generate faster machine code (_Thread_local vs pthread_key). Pthreads has more features though.”
2026 verdict: learn pthreads for this phase. It’s what every existing codebase uses, what study partners ask about, what StackOverflow answers use, and what Linux tooling (helgrind, TSan, gdb thread commands) understands natively. C11 threads are still not universally available on all platforms in 2026 (macOS libc famously lagged for years). Once pthreads is second nature, C11 threads is a 15-minute API translation.
Thread lifecycle: create, join, detach¶
#include <pthread.h>
void *worker(void *arg) {
int id = *(int*)arg;
printf("worker %d\n", id);
return (void*)(intptr_t)(id * 2); // return value returned via join
}
pthread_t t;
int id = 7;
pthread_create(&t, NULL, worker, &id);
void *retval;
pthread_join(t, &retval); // blocks until t exits; collects retval
printf("worker returned %ld\n", (intptr_t)retval);
A thread must be either joined or detached. If you neither join nor detach a thread, its resources (kernel stack, TLS, etc.) leak until process exit. Detached threads (pthread_detach(t) or created with pthread_attr_setdetachstate) clean themselves up on exit but you cannot recover their return value.
Pointer-lifetime trap: the &id you pass to pthread_create must live long enough for the child to read it. In a loop:
for (int i = 0; i < 10; i++) {
pthread_create(&t[i], NULL, worker, &i); // WRONG — all workers see whatever i is now
}
All ten workers race to read i, and by the time they do, i may be 10. Fix: allocate per-thread arg on the heap, or use an array int ids[10]; ids[i] = i; pthread_create(..., &ids[i]);, or pack the int into the void*: pthread_create(..., (void*)(intptr_t)i).
Mutexes¶
A mutex protects a critical section. Only one thread can hold the mutex at a time; other threads that call pthread_mutex_lock block until the holder unlocks.
pthread_mutex_t m = PTHREAD_MUTEX_INITIALIZER;
pthread_mutex_lock(&m);
/* critical section — exclusive access to shared data */
pthread_mutex_unlock(&m);
For a mutex that’s not statically initialized, use pthread_mutex_init(&m, NULL) and pthread_mutex_destroy(&m). If you need special properties (recursive, error-checking, process-shared) you pass an attributes object.
Types you should know:
PTHREAD_MUTEX_NORMAL— default. Deadlocks on double-lock (which is a bug you want to catch anyway).PTHREAD_MUTEX_ERRORCHECK— double-lock returnsEDEADLKinstead of hanging. Use this during development.PTHREAD_MUTEX_RECURSIVE— same thread can lock multiple times, must unlock same number of times. Occasionally useful for callback-heavy APIs but usually a smell that your locking design is confused.
Rules that will save you weeks of debugging:
Every shared piece of data has an associated lock. Write it down. In a comment above the struct. If you can’t name the lock that protects
x,xisn’t safe to touch.Never call user callbacks or unknown functions while holding a lock. They may re-enter your code or call something that also locks, and you have a deadlock or a violated invariant.
Keep critical sections short. Long critical sections serialize your program. If the critical section allocates memory or does I/O, you probably need a redesign.
Prefer
pthread_mutex_trylockfor lock-ordering hazards. If a would-be nested lock can’t be acquired, back off and retry.
Condition variables — the pattern you must not get wrong¶
A condition variable is not a lock. It’s a waiting list: threads sleep on it until another thread signals them. It’s always paired with a mutex, because the condition being waited on is stored in shared data that the mutex protects.
The wait-loop-while-condition idiom is non-negotiable:
pthread_mutex_t m = PTHREAD_MUTEX_INITIALIZER;
pthread_cond_t c = PTHREAD_COND_INITIALIZER;
int ready = 0;
/* consumer */
pthread_mutex_lock(&m);
while (!ready) { // WHILE, not IF
pthread_cond_wait(&c, &m);
}
/* now ready is true, and we still hold m */
ready = 0; // consume
pthread_mutex_unlock(&m);
/* producer */
pthread_mutex_lock(&m);
ready = 1;
pthread_cond_signal(&c); // or pthread_cond_broadcast
pthread_mutex_unlock(&m);
Why while and not if? Two reasons:
Spurious wakeups: POSIX explicitly permits
pthread_cond_waitto return without a matching signal. This is not a bug in your OS — it’s allowed by spec, and Linux futex implementations do produce them in real systems. If you useif, your thread wakes up,readyis still 0, and you proceed as if it wasn’t, corrupting state.Broadcast wakeups:
pthread_cond_broadcastwakes all waiters. Multiple wake up, one grabs the resource; the others must re-check the condition and re-wait if the resource is gone.
How pthread_cond_wait actually works:
Atomically unlocks
mand puts the thread to sleep onc. (Atomic is key — no race between unlock and sleep.)When woken (by signal, broadcast, or spuriously), re-locks
mbefore returning.
So you enter with the lock, and you exit with the lock. In between, you’re asleep and the lock is held by no one.
Producer-consumer bounded queue — the canonical example:
#define CAP 64
int buf[CAP], head=0, tail=0, count=0;
pthread_mutex_t m = PTHREAD_MUTEX_INITIALIZER;
pthread_cond_t not_empty = PTHREAD_COND_INITIALIZER;
pthread_cond_t not_full = PTHREAD_COND_INITIALIZER;
void put(int v) {
pthread_mutex_lock(&m);
while (count == CAP) pthread_cond_wait(¬_full, &m);
buf[tail] = v; tail = (tail+1) % CAP; count++;
pthread_cond_signal(¬_empty);
pthread_mutex_unlock(&m);
}
int get(void) {
pthread_mutex_lock(&m);
while (count == 0) pthread_cond_wait(¬_empty, &m);
int v = buf[head]; head = (head+1) % CAP; count--;
pthread_cond_signal(¬_full);
pthread_mutex_unlock(&m);
return v;
}
Learn this until you can write it in your sleep. It’s the seed of every task queue you’ll ever build.
Read-write locks (pthread_rwlock_t)¶
When a piece of shared data is read far more often than written, an rwlock lets you have arbitrarily many concurrent readers or one exclusive writer.
pthread_rwlock_t rw = PTHREAD_RWLOCK_INITIALIZER;
pthread_rwlock_rdlock(&rw); /* many readers OK */
/* read shared data */
pthread_rwlock_unlock(&rw);
pthread_rwlock_wrlock(&rw); /* exclusive */
/* mutate shared data */
pthread_rwlock_unlock(&rw);
Caveats:
Rwlocks are not free: they’re heavier than plain mutexes. If your critical section is only a few instructions, a plain mutex is faster.
Writer starvation is possible if readers keep arriving; Linux glibc’s default is writer-preferring since ~2.24 (2016), but check yours.
For truly read-heavy data structures at high concurrency, RCU (Read-Copy-Update) — covered in McKenney’s perfbook — is dramatically faster than rwlocks. Beyond this phase; know the name.
Thread-local storage¶
_Thread_local int counter = 0; (or the older __thread GCC extension) gives every thread its own copy of counter. Amazing for per-thread scratch buffers, per-thread stat counters, and avoiding false sharing.
_Thread_local char scratch[4096];
void *worker(void *arg) {
// scratch is mine, no locking needed
return NULL;
}
The older pthreads equivalent is pthread_key_create + pthread_setspecific / pthread_getspecific — more code, one function call per access. Prefer _Thread_local.
The five most common pthread mistakes¶
From kaiwantech’s widely-cited “Top 5 pthread mistakes” (r/C_Programming, 2024):
Not recognizing shared data. A global, a
staticin a function, anything reachable through a pointer passed to another thread — shared. Any read/write on it needs a lock or an atomic.Assuming bit/byte/word stores are atomic. They’re not, in general, without
_Atomicoratomic_store. Even aligned word stores can be split on some ISAs. This is what<stdatomic.h>(file 02) exists to fix.Not blocking signals when using
sigwait(3). Signals must be blocked in all threads (typically at process start, before creating threads) so thatsigwaitin a dedicated thread reliably catches them.Passing pointers to stack-allocated data to threads. The stack frame dies when the calling function returns; the thread now has a dangling pointer.
Not using
pthread_cond_waitin awhileloop. Covered above. It’s the single most common concurrency bug in C code, in industry, still, in 2026.
What most people get wrong about this¶
They use condition variables like they’re events (“signal fires, waiter wakes up, done”). They’re not. They’re a wait-and-recheck mechanism. The condition is the shared state; the cond var is only how you sleep efficiently until re-checking is worthwhile. Signals can be lost (delivered when no one is waiting), spurious (delivered when no one signaled), or coalesced (many signalings, one wakeup). Only the shared state under the lock is authoritative.
Practice this week¶
Write the bounded queue above from scratch. Then start 4 producers and 4 consumers pushing/popping 100k items each. Verify total sum in equals sum out.
Introduce a deliberate bug: use
ifinstead ofwhileonpthread_cond_wait. Run under load; observe eventual corruption.Convert your bounded queue’s
pthread_cond_signaltopthread_cond_broadcast. Measure throughput difference at 16 threads. Understand why signal is usually enough for producer-consumer.Write a small “count words in a directory” program that spawns one thread per file. Then reimplement with a fixed thread pool of 4 (using your bounded queue as the task queue). Measure. The pool wins because thread creation isn’t free.
Read
man pthreads,man pthread_mutex_lock,man pthread_cond_waitcover to cover. Yes, all three.
References¶
David Butenhof, Programming with POSIX Threads — the reference. Chapters 3-5.
Paul McKenney, perfbook ch. 4-7 (locking, deferred processing).
LLNL POSIX Threads tutorial (hpc-tutorials.llnl.gov/posix/) — concise, still current.
man 7 pthreads,man 3 pthread_*.
Return to README.md · Next: 02_memory_model_and_atomics.md