14. Multithreading in C: pthreads and C11¶
Time: ~3 hours Prerequisites: modules 07 (function pointers), 09, 10; 04_Concurrency modules 01 to 03 (sections 1 and 2) for the concepts
1. In plain words¶
Threads in one process share everything: globals, the heap, open files. That is what makes them fast to communicate and dangerous to combine. The one rule that matters: if one thread writes a variable and another thread reads or writes it, every access must be protected, either by a lock that both threads take, or by making the variable atomic. Break the rule and you have a data race, which in C is undefined behaviour (module 09), not just "a wrong number sometimes".
Picture a bank branch with one shared ledger.
- A mutex is the single pen chained to the ledger. Whoever holds the pen may write; everyone else waits.
- A condition variable is the waiting room with a bell. A clerk who needs "a customer in the queue" sits down and releases the pen; whoever adds a customer rings the bell. The clerk wakes up, takes the pen back, and checks the queue again before acting, because someone else may have served that customer first.
- A read-write lock is the reading room: many people may read the rate sheet at once, but updating it needs the room empty.
- An atomic is a counter that clicks forward in one indivisible step, so two clerks pressing it together still count two.
You maintain a multithreaded Win32 server, so you have seen CRITICAL_SECTION and friends. This
module is the same ideas in the POSIX and C11 APIs that Linux systems interviews expect, with a
Win32 mapping table, and it ends with ThreadSanitizer, the tool that finds races for you.
2. The real thing¶
2.1 Creating and joining threads¶
int pthread_create(pthread_t *thread, const pthread_attr_t *attr,
void *(*start)(void *), void *arg);
int pthread_join(pthread_t thread, void **retval);
int pthread_detach(pthread_t thread);
- Arguments in: pass a pointer to a struct that lives at least as long as the thread uses it.
The classic bug is
for (int i = 0; i < 4; i++) pthread_create(&t[i], NULL, f, &i);: all four threads read the samei, which keeps changing and dies when the loop ends. Give each thread its own struct (an array of job structs indexed by thread). - Results out: write them into the caller-owned job struct, or return a heap pointer from the
thread function, which
pthread_joinhands back and the joiner frees. - Join waits for the thread and also makes everything it wrote visible to the joiner.
- Detach says "nobody will join this thread"; its resources are freed when it exits. A detached thread must signal completion some other way (a semaphore, a condition variable) and must not touch anything that the rest of the program may free first, which makes them a shutdown hazard.
- Every joinable thread must be joined or detached, or its stack and descriptor leak.
- pthread functions return an error number (0 on success) and do not set
errno. Usestrerror(rc), notperror.
2.2 Mutexes¶
static pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;
pthread_mutex_lock(&lock);
balance += amount; /* the critical section: as short as possible */
pthread_mutex_unlock(&lock);
- Default mutexes are not recursive: locking twice in one thread deadlocks. Win32's
CRITICAL_SECTIONis recursive, which hides design problems that appear when code is ported. PTHREAD_MUTEX_ERRORCHECK(set withpthread_mutexattr_settype) returnsEDEADLKon a relock andEPERMon unlock by a non-owner. Use it in debug builds of code you are not sure about.- Lock ordering prevents deadlock: if any code path holds A while taking B, then no path may hold B while taking A. Document the order next to the lock declarations.
- Never hold a lock across blocking I/O (a socket read, a database call, a log write to a slow disk). Copy what you need, unlock, then do the I/O.
2.3 Condition variables and the predicate loop¶
pthread_mutex_lock(&q->mu);
while (q->count == 0 && !q->closed) /* WHILE, never if */
pthread_cond_wait(&q->not_empty, &q->mu); /* atomically: unlock, sleep, relock */
/* here: the predicate is true and we hold the lock */
pthread_cond_waitmust be called with the mutex held. It releases the mutex and sleeps as one atomic step, so a signal sent between "check" and "sleep" cannot be lost, and it reacquires the mutex before returning.- Why
while, notif. POSIX allows spurious wakeups (returning with no signal), and even a real wakeup can be stolen: another thread takes the item between the signal and your wakeup. Re-checking the predicate handles both. - The predicate lives in shared state protected by the mutex. A condition variable has no memory; signalling when nobody waits does nothing. A "flag only" design that waits without checking state loses wakeups.
pthread_cond_signalwakes at least one waiter;pthread_cond_broadcastwakes all. Use broadcast when the state change may let several threads proceed, and always for shutdown.- Timed waits take an absolute deadline. By default it is measured on
CLOCK_REALTIME, which jumps when NTP or an administrator changes the clock; setCLOCK_MONOTONICwithpthread_condattr_setclockand compute the deadline fromclock_gettime(CLOCK_MONOTONIC). - Shutdown: set a
closedflag under the lock and broadcast; every wait loop checks the flag.
2.4 Read-write locks¶
pthread_rwlock_rdlock lets many readers in together; pthread_rwlock_wrlock waits until there
are none. Worth it only for read-mostly data with critical sections long enough to amortise the
more expensive lock; for tiny critical sections a plain mutex is often faster. Watch for writer
starvation: glibc's default rwlock prefers readers, so a steady stream of readers can keep a writer
waiting indefinitely (glibc offers PTHREAD_RWLOCK_PREFER_WRITER_NONRECURSIVE_NP as an option).
2.5 One-time initialisation¶
pthread_once(&once, init_fn) runs init_fn exactly once even if many threads call it
together, and every caller returns only after it has finished, with its writes visible. It is the
correct form of "lazy global init", which written by hand with a flag is a data race (and the
classic broken double-checked locking).
2.6 C11 <threads.h> and <stdatomic.h>¶
| Purpose | pthreads | C11 | Win32 |
|---|---|---|---|
| create / wait | pthread_create / pthread_join |
thrd_create / thrd_join |
_beginthreadex / WaitForSingleObject + CloseHandle |
| thread function | void *f(void *) |
int f(void *) |
unsigned __stdcall f(void *) |
| mutex | pthread_mutex_t |
mtx_t (mtx_plain, mtx_recursive, mtx_timed) |
CRITICAL_SECTION, SRWLOCK |
| condition variable | pthread_cond_t |
cnd_t |
CONDITION_VARIABLE + SleepConditionVariableCS/SRW |
| read-write lock | pthread_rwlock_t |
none | SRWLOCK (shared / exclusive) |
| once | pthread_once |
call_once |
InitOnceExecuteOnce |
| thread-local | pthread_key_create or _Thread_local |
tss_create, thread_local |
TlsAlloc, __declspec(thread) |
| atomic add | _Atomic / GCC builtins |
atomic_fetch_add |
InterlockedIncrement, InterlockedAdd |
| semaphore | sem_t (POSIX, separate header) |
none | CreateSemaphoreW |
The full Win32 reference, with code, is 04_Concurrency/13_win32_concurrency_reference.md.
C11 threads are portable but thinner: no read-write lock, no thread attributes (stack size),
and timed waits only against TIME_UTC (wall clock). glibc has shipped them since 2.28 and MSVC
since Visual Studio 2022 17.8.
<stdatomic.h> is the part of C11 everyone uses:
static atomic_long hits;
atomic_fetch_add_explicit(&hits, 1, memory_order_relaxed); /* a pure counter */
g_msg.amount = 250000; /* plain data ... */
atomic_store_explicit(&ready, 1, memory_order_release); /* ... then publish */
while (!atomic_load_explicit(&ready, memory_order_acquire)) ; /* reader: see the flag */
use(g_msg.amount); /* ... then the data */
memory_order_relaxed: the operation is atomic, nothing else is ordered. Right for counters.releaseon a store andacquireon the load that reads it: everything written before the release is visible after the acquire. This is how one thread hands data to another without a lock.seq_cst(the default of the non-_explicitfunctions): one global order of all such operations. Safest, sometimes slower.atomic_flagis the one type guaranteed lock-free: enough to build a spinlock.
The theory, with diagrams, is 04_Concurrency/07_atomics_and_the_memory_model.md. The practical rule: use a mutex unless you can say exactly which memory order you need and why.
2.7 Thread-safe, reentrant, async-signal-safe¶
| Property | Meaning | Example |
|---|---|---|
| Thread-safe | correct when called from several threads at once | malloc, printf (it locks the FILE) |
| Reentrant | correct when called again before the previous call finished in the same thread (nested, recursive, from a handler) | strtok_r, localtime_r, anything with only arguments and locals |
| Async-signal-safe | may be called from a signal handler | write, _exit, sem_post; not printf, malloc |
Functions with hidden static state are neither thread-safe nor reentrant, and POSIX gives most of
them an _r twin: strtok -> strtok_r, localtime / gmtime / ctime -> localtime_r /
gmtime_r / ctime_r, rand -> rand_r, strerror -> strerror_r, getpwnam ->
getpwnam_r; gethostbyname and inet_ntoa are replaced by getaddrinfo and inet_ntop.
Moving a static result buffer to _Thread_local makes a function thread-safe but still not
reentrant: two calls in one printf still overwrite each other (m14_reentrant.c proves both).
errno itself is thread-local, which is why it works at all in threaded code.
2.8 A thread pool in C¶
A fixed set of worker threads takes tasks (a function pointer plus an argument) from a queue:
submit(fn, arg) -> [ lock | append task | pending++ | signal has_work | unlock ]
worker loop -> [ lock | while (no task && !stopping) wait(has_work) | pop | unlock ]
run fn(arg) WITHOUT the lock
[ lock | pending-- | if 0: broadcast idle | unlock ]
wait_idle() -> [ lock | while (pending > 0) wait(idle) | unlock ]
destroy() -> [ lock | stopping = 1 | broadcast has_work | unlock ] then join every worker
The design decisions: the task runs outside the lock; pending counts queued plus running tasks
so wait_idle is exact; shutdown drains the queue before workers exit; and pool_create joins
the threads it already started if a later pthread_create fails. Sizing and queue policies are
in 04_Concurrency/09_thread_pools_and_task_queues.md.
2.9 ThreadSanitizer¶
Build with -fsanitize=thread -g -O1 and run. TSan records which thread accessed each memory
location under which locks, and reports two accesses that are not ordered by any
synchronisation (a "happens-before" relation). This tiny racy program (counter++ from two
threads, no lock) is the canonical example:
static long counter;
static void *work(void *arg) { (void)arg; for (int i = 0; i < 100000; i++) counter++; return NULL; }
Built with plain -O2 it printed counter = 200000 on both runs: GCC turned the loop into a
single counter += 100000, so the lost updates you expect did not appear. The race is still
there and the next compiler may expose it. TSan reports it regardless (real output, paths
shortened):
WARNING: ThreadSanitizer: data race (pid=1554)
Read of size 8 at 0x555555558018 by thread T2:
#0 work race.c:4 (race_t+0x1263)
Previous write of size 8 at 0x555555558018 by thread T1:
#0 work race.c:4 (race_t+0x128d)
Location is global 'counter' of size 8 at 0x555555558018 (race_t+0x4018)
...
SUMMARY: ThreadSanitizer: data race race.c:4 in work
On this WSL kernel TSan binaries sometimes abort with "unexpected memory mapping"; run them as
setarch $(uname -m) -R ./prog (it disables address randomisation for that run).
A verified TSan limitation. With GCC 13.3 and clang 18.1 on glibc 2.39, a TSan build that uses
glibc's <threads.h> crashes at start-up:
ThreadSanitizer:DEADLYSIGNAL
==1530==ERROR: ThreadSanitizer: SEGV on unknown address 0x000000000018 (pc 0x7ffff6eb3f90 ...)
glibc's thrd_create, mtx_lock and cnd_wait call glibc's internal pthread functions directly,
so TSan's interceptors (which hook the public pthread_* symbols) never see the new thread or the
locks. m14_c11_threads_atomics.c therefore has a small -DUSE_PTHREADS shim that maps the C11
names onto pthreads for the TSan build. That is worth knowing before you choose C11 threads for a
codebase you want to race-check.
3. Code¶
All five programs build with -pthread, pass their self-checks, and run clean under TSan using
the command in each file's TSan: header line.
3.1 m14_pthreads_basics.c¶
Arguments in through per-thread job structs, a result returned as a heap pointer, a mutex
counter, pthread_once, a read-write lock with an invariant check, and a detached thread that
reports through a semaphore. Real output:
parallel sum over 4 threads: 2999997
mutex counter: 400000 (expected 400000)
rwlock: 60000 reads during 200 updates, torn reads = 0
all checks passed
The rwlock test is the useful pattern: the writer updates four fields that must stay equal, and the readers count how often they see them unequal. Zero torn reads is the evidence that the lock protects the invariant, not just the individual fields.
3.2 m14_bounded_queue.c¶
One mutex, two condition variables (not_empty, not_full), predicate loops, a timed pop on
CLOCK_MONOTONIC, and bq_close that broadcasts. Four producers push 20 000 values each through
a 16-slot queue to four consumers:
4 producers -> 4 consumers: 80000 items, sum 800040000 (expected 800040000)
timed pop on empty queue: ETIMEDOUT, waited at least 45 ms
all checks passed
The deadline arithmetic is a classic bug site, so it is worth reading:
struct timespec deadline;
clock_gettime(CLOCK_MONOTONIC, &deadline); /* absolute deadline, computed once */
deadline.tv_sec += timeout_ms / 1000;
deadline.tv_nsec += (timeout_ms % 1000) * 1000000L;
if (deadline.tv_nsec >= 1000000000L) { /* normalise, or timedwait fails with EINVAL */
deadline.tv_sec++;
deadline.tv_nsec -= 1000000000L;
}
Computing the deadline once, before the loop, means spurious wakeups do not extend the total wait.
3.3 m14_thread_pool.c¶
static void *worker_main(void *arg)
{
struct pool *p = arg;
for (;;) {
pthread_mutex_lock(&p->mu);
while (!p->head && !p->stopping)
pthread_cond_wait(&p->has_work, &p->mu);
if (!p->head) { /* stopping and nothing left */
pthread_mutex_unlock(&p->mu);
return NULL;
}
struct task *t = p->head;
p->head = t->next;
if (!p->head)
p->tail = NULL;
pthread_mutex_unlock(&p->mu);
t->fn(t->arg); /* run WITHOUT the lock: tasks may be slow */
free(t);
pthread_mutex_lock(&p->mu);
if (--p->pending == 0)
pthread_cond_broadcast(&p->idle);
pthread_mutex_unlock(&p->mu);
}
}
Real output:
10000 square tasks done on 4 workers
50000 counter tasks drained at shutdown: hits = 50000
all checks passed
Compare it line by line with the C++ pool in 04_Concurrency/code/m09_thread_pool.hpp: the
logic is the same, and most of the extra C lines are cleanup that destructors would do.
3.4 m14_c11_threads_atomics.c¶
thrd_create, mtx_t, cnd_t with a predicate loop and call_once; a relaxed atomic counter;
a spinlock from atomic_flag; release/acquire message passing; and thread_local:
atomic counter = 400000, spinlock counter = 400000
message passed with release/acquire: account 1001 amount 250000
all checks passed
3.5 m14_reentrant.c¶
/* Count "key=value" pairs in "a=1;b=2;c=3". The inner strtok call resets the hidden cursor,
* so the outer loop loses its place after the first pair. */
for (char *item = strtok(s, ";"); item; item = strtok(NULL, ";")) {
char *key = strtok(item, "=");
char *val = strtok(NULL, "=");
if (key && val)
pairs++;
}
Real output:
pairs counted with strtok: 1, with strtok_r: 3
errno per thread: 22 and 12 survived each other
all checks passed
The strtok bug needs no threads at all: a nested call in one thread is enough. That is the
difference between "not reentrant" and "not thread-safe". The file also shows that
localtime() returns the same pointer on every call and that a _Thread_local buffer gives
each thread its own copy.
4. Under the hood (senior-level)¶
A mutex is a futex. glibc's pthread_mutex_t (40 bytes on x86-64) holds an integer state.
An uncontended lock is one atomic compare-and-swap in user space: no system call. Only when the
CAS fails does the thread call futex(FUTEX_WAIT) to sleep in the kernel on that address, and
unlock calls futex(FUTEX_WAKE) only if someone is waiting. That is why uncontended locks cost
tens of nanoseconds and contended ones cost microseconds (typical figures): the expensive part is
contention, not locking. Condition variables are built on futexes too, with sequence counters so
that wakeups are not lost between the unlock and the sleep.
What pthread_create does. glibc mmaps a stack (8 MiB by default, from ulimit -s) with a
guard page below it, sets up the thread's TLS block and its thread control block, and calls
clone with flags that share the address space, files, filesystem info and signal handlers
(CLONE_VM | CLONE_FS | CLONE_FILES | CLONE_SIGHAND | CLONE_THREAD | ...). To the kernel a thread
is a task that happens to share memory. _Thread_local variables live in the TLS block, addressed
through the %fs segment register on x86-64; errno is one of them.
Memory ordering on x86. x86-64 is close to sequentially consistent ("total store order"):
the only reordering the hardware does is a later load passing an earlier store to a different
address. So acquire loads and release stores compile to plain mov instructions; the
memory_order still matters because it stops the compiler from reordering. A seq_cst store
needs xchg (or mov plus mfence). On ARM, which reorders much more, acquire and release
become ldar and stlr. Code that "works on x86" with the wrong order can fail on an ARM server.
Why spinlocks are rarely right in user space. A spinning thread burns its time slice; if the
lock holder was preempted, every waiter spins until the holder runs again. A futex mutex spins
briefly (glibc's adaptive mutex type) and then sleeps. Spinlocks fit kernels with preemption
disabled, not application servers. The atomic_flag spinlock in m14_c11_threads_atomics.c is
there to teach acquire and release, and it yields instead of spinning hard.
How TSan works. Every memory access is instrumented; for each 8-byte word TSan keeps shadow
cells recording the last few accesses with the thread id and a vector clock. Locks, atomics,
thread create and join update the clocks. Two accesses to one word, at least one a write, where
neither clock happens-before the other, form a race. The shadow memory is why TSan needs a fixed
address-space layout (hence the setarch -R workaround) and costs about 5 to 15 times in CPU
(typical).
5. In production¶
- Locks held across I/O are the top latency bug. A thread takes the session-table lock,
then writes a log line to a slow disk or calls the database. Every other request queues
behind it. In a dump, many threads are blocked in
EnterCriticalSectionorfutex_waiton the same lock, and one thread holding it is inWriteFileorrecv. Fix: copy what you need under the lock, release, then do I/O. - Lock-order inversion. Path one locks the connection table, then a session; path two locks a
session, then the connection table to remove itself. Under load they deadlock. In gdb:
thread apply all btshows two threads each waiting for the lock the other holds; in WinDbg,!locksand~*kb. The fix is a documented order (04_Concurrency/06_deadlocks_livelocks_starvation.md). ifinstead ofwhilearound a wait. Works for months, then a second consumer thread is added and the queue pop crashes on an empty queue once a week. Grep everypthread_cond_waitandSleepConditionVariable*call for a surroundingwhile.- Non-reentrant library calls in a threaded server.
localtime,strtok,inet_ntoaandgethostbynameused from worker threads give log lines with another request's timestamp or a corrupted token. They are easy to find with grep and easy to replace with the_rversions. On MSVC the CRT makes many of these per-thread, so code ported to Linux can break. - Shutdown races. At exit,
mainfrees globals while a detached thread still uses them, or a worker is still running a task when the pool's memory is freed. Make shutdown explicit: set the stop flag, wake everyone, join every thread, then free. - Run TSan on the test suite. It finds races that never reproduced under a debugger. Add a TSan build to CI for any component with threads, and treat every report as a bug (04_Concurrency/12_debugging_concurrency_bugs.md).
6. Try it¶
- Easy - the loop-variable bug. Write a program that passes
&ifrom aforloop to four threads that print it. Run it a few times, then run it under TSan. Fix it with an array of ints. Done when you can explain the original output and TSan's report. - Easy - error numbers. Lock a default mutex twice from one thread with a 1-second
pthread_mutex_timedlock, then repeat with anERRORCHECKmutex and plainlock. Done when you can showETIMEDOUTin the first case andEDEADLKin the second, printed withstrerror. - Medium - break the queue. Change one
whileinm14_bounded_queue.ctoifand run the test 200 times in a loop, then under TSan. Done when you either catch a failure or can explain why it is still wrong even though you did not. - Medium - priority pool. Give
m14_thread_pool.ctwo priority levels (two queues; workers always drain the high queue first). Done when a test with a slow low-priority flood shows high-priority tasks still finish quickly, and TSan is clean. - Hard - rwlock versus mutex. Benchmark the rate table from
m14_pthreads_basics.cwith a read-write lock and with a mutex, for 1, 2, 4 and 8 reader threads and a critical section of 10 ns and 1 microsecond (busy loop). Done when you have a table and can explain where the rwlock starts to win. - Hard - Win32 port. Port
m14_bounded_queue.cto Win32 withSRWLOCKandCONDITION_VARIABLE(SleepConditionVariableSRW,WakeConditionVariable,WakeAllConditionVariable). Done when it passes the same test on Windows; compare with 04_Concurrency/code/m13_win32_srwlock_condvar.c.
7. Interview questions¶
Q: Why must pthread_cond_wait be called in a while loop?
A: POSIX allows spurious wakeups, and even a real wakeup can find the condition false again
because another thread consumed the state between the signal and this thread reacquiring the
mutex. Re-checking the predicate under the mutex after every wakeup handles both cases. The
predicate must be shared state protected by the same mutex.
Q: What does pthread_cond_wait do with the mutex?
A: It must be called with the mutex held. It atomically releases the mutex and puts the thread to
sleep, so no signal can slip in between the check and the sleep, and it reacquires the mutex before
returning, so the caller can safely re-check the predicate.
Q: What is the difference between thread-safe and reentrant?
A: Thread-safe means correct when called concurrently from several threads, possibly using locks
or per-thread state. Reentrant means correct when re-entered before a previous call in the same
thread has finished, as with recursion, nested calls or signal handlers, which requires no hidden
shared state. strtok is neither; a version with a thread-local cursor would be thread-safe but
still not reentrant; strtok_r is both.
Q: When do you use an atomic instead of a mutex? A: For a single variable updated with a simple operation, such as a counter, a flag or a pointer swap, where an atomic read-modify-write is enough. As soon as an invariant spans several variables, use a mutex. Atomics also require choosing a memory order; relaxed suffices for a pure counter, release/acquire for publishing data.
Q: Is volatile enough for a flag shared between threads?
A: No. volatile forces the compiler to load and store the variable each time, but it gives no
atomicity and no ordering with respect to other memory, and a concurrent non-atomic access is still
a data race and therefore undefined. Use _Atomic (for example atomic_bool with release/acquire)
or a mutex.
Q: How do you shut down a thread pool cleanly? A: Under the queue lock set a stopping flag and broadcast the condition variable so every idle worker wakes. Workers finish (or drain) the queue, see the flag with an empty queue and return. The owner joins every worker before freeing the pool, so no thread touches freed memory.
Q: How does a futex-based mutex avoid system calls?
A: The lock word lives in user memory; an uncontended lock or unlock is a single atomic
instruction. Only on contention does a thread call the futex system call to sleep on that address,
and unlock wakes a sleeper through futex only if the state shows there are waiters.
Q: How do you find a data race?
A: Build with ThreadSanitizer (-fsanitize=thread -g -O1) and run the tests or a load test; it
reports both conflicting accesses with stacks and the threads' creation points. Races rarely show up
in a debugger because breakpoints change timing, so TSan, Helgrind and careful review of which lock
protects each shared field are the main tools.
8. Recap¶
- A variable written by one thread and accessed by another needs a lock or an atomic; anything else is a data race and undefined behaviour.
pthread_createtakes avoid *(*)(void *); give each thread its own argument struct; join or detach every thread; pthread calls return error numbers, noterrno.- Mutex: short critical sections, a documented lock order, never across blocking I/O.
- Condition variable: wait in a
whileon a predicate protected by the mutex; broadcast for shutdown; absolute deadlines onCLOCK_MONOTONIC. - rwlock for read-mostly data with non-trivial critical sections; watch writer starvation.
pthread_once/call_oncefor one-time init; never a hand-rolled flag.- C11:
thrd_t,mtx_t,cnd_t,call_once,thread_local, and<stdatomic.h>with relaxed, release/acquire and seq_cst orders. - Thread-safe is not reentrant: static buffers break both;
_Thread_localfixes only the first; use the_rfunctions. - A thread pool: queue under one mutex, tasks run unlocked, pending counter for wait-idle, drain and join on shutdown.
- TSan finds races that tests and debuggers miss; on this setup it needs
setarch -R, and it cannot see glibc's<threads.h>, so race-check C11 threads code through pthreads.