Linux System Programming · intermediate · ~12 min
- By the end you can explain what a critical section is and wrap one in a `pthread_mutex_t` using the lock → modify → unlock pattern. - By the end you can initialize a mutex both statically (`PTHREAD_MUTEX_INITIALIZER`) and dynamically (`pthread_mutex_init`), and clean it up correctly. - By the end you can identify why every read *and* every write of shared state must be under the same lock. - By the end you can guarantee a matching unlock on every exit path, including error paths, so you never leave a lock held. - By the end you can reason about lock granularity — holding the lock for as little code as possible — and avoid the classic beginner deadlock.
In the previous lesson on race conditions you saw that when two threads touch the same variable without coordination, updates get lost: counter++ is really load, add, store, and two threads can interleave those steps so one increment vanishes. That lesson showed you the problem. This lesson gives you the standard fix: the mutex.
A mutex (mutual-exclusion lock) is an object that only one thread can "hold" at a time. You wrap the dangerous code — the critical section — between a lock and an unlock. While one thread holds the lock, any other thread that tries to lock waits its turn. That single guarantee turns the non-atomic counter++ into an operation no other thread can interleave with, which is exactly what was missing before.
Nearly every real multithreaded program — a web server tracking connection counts, a database buffer pool, a logging subsystem, a reference-counted object — protects its shared state with a mutex. Getting it wrong produces the nastiest class of bugs in systems programming: data races that corrupt memory intermittently and vanish under a debugger, and deadlocks that hang the whole process. In security terms, an unsynchronized shared counter or free-list is a classic source of double-free and use-after-free vulnerabilities, because a torn or lost update can leave an allocator or reference count in an impossible state that an attacker can steer.
A critical section is any span of code that reads or writes shared mutable state and would misbehave if two threads ran it at once. A mutex protects a critical section. The pattern never changes:
lock → touch shared state → unlock
pthread_mutex_t m = PTHREAD_MUTEX_INITIALIZER;
pthread_mutex_lock(&m);
/* critical section: exactly one thread is here at a time */
counter++;
pthread_mutex_unlock(&m);
Think of the lock as a single key on a hook. To enter the room you must take the key; if it is gone, you wait by the hook. When you leave you hang the key back. Only one person is ever in the room.
Thread A mutex (key) Thread B
-------- ----------- --------
lock() ----------> [taken by A]
counter load lock() --.
counter store (blocked) | waits
unlock() ---------> [free] <-'
[taken by B] <------ lock() resumes
counter load
counter store
unlock()
Because B physically cannot execute its load/store until A has finished and unlocked, the two increments can no longer interleave. The lost update from the race-conditions lesson is gone.
A mutex must be initialized exactly once before first use. There are two ways:
| Style | How | When to use | Cleanup |
|---|---|---|---|
| Static | pthread_mutex_t m = PTHREAD_MUTEX_INITIALIZER; |
File-scope or otherwise fixed-lifetime mutex with default attributes | Optional pthread_mutex_destroy |
| Dynamic | pthread_mutex_init(&m, attr) |
Mutex lives in heap-allocated memory (e.g. embedded in a struct you malloc), or you need non-default attributes |
Must call pthread_mutex_destroy(&m) before freeing |
The static form is simplest and is what you use for a global lock. The dynamic form is required when the mutex is a field inside an object you allocate at runtime, because a struct you malloc cannot use the static initializer.
Knowledge check: you added a pthread_mutex_t lock; field to a struct account that you allocate with malloc. How do you initialize and later dispose of that lock?
Call
pthread_mutex_init(&acct->lock, NULL)right after allocating (you cannot assignPTHREAD_MUTEX_INITIALIZERto a runtime-allocated field portably), and callpthread_mutex_destroy(&acct->lock)beforefree(acct). Destroying after free, or freeing while another thread might still lock it, is undefined behaviour.
A subtle but critical rule: reads of the shared variable must also be under the lock, not just writes. If one thread updates a multi-word value while another reads it lock-free, the reader can observe a torn value — a mix of old and new bytes that was never a valid state. Even for a single long, reading without the lock is a data race, which the C standard declares undefined behaviour; the compiler may then cache the value in a register and never see another thread's update. The rule: every thread that touches a piece of shared state must hold the same mutex while doing so.
While a thread holds the lock, every other thread that needs it is stalled. So do the minimum inside the lock and push slow work (I/O, malloc, formatting, long loops) outside it.
/* BAD: holds the lock across a slow syscall — everyone stalls */
pthread_mutex_lock(&m);
read(fd, buf, sizeof buf); /* could block for seconds */
total += n;
pthread_mutex_unlock(&m);
/* GOOD: slow work outside, lock only the shared update */
ssize_t n = read(fd, buf, sizeof buf);
pthread_mutex_lock(&m);
total += n;
pthread_mutex_unlock(&m);
Knowledge check: why is holding a mutex across a blocking read() dangerous beyond just being slow?
Besides serializing all threads behind the slow call, it can cause deadlock: if the thread is waiting on data that only arrives after another thread — which is now blocked on your mutex — does its work, neither can proceed. Locks should protect memory, not I/O.
/* Static initializer: default attributes, no destroy required. */
pthread_mutex_t m = PTHREAD_MUTEX_INITIALIZER;
/* Dynamic init. attr = NULL means default attributes. Returns 0 on success,
or an errno-style positive error code (does NOT set errno). */
int pthread_mutex_init(pthread_mutex_t *mutex, const pthread_mutexattr_t *attr);
/* Blocks until the calling thread owns the lock. Returns 0, or an error code
(e.g. EDEADLK if a default mutex detects self-relock, on some systems). */
int pthread_mutex_lock(pthread_mutex_t *mutex);
/* Non-blocking attempt. Returns 0 if acquired, EBUSY if already held. */
int pthread_mutex_trylock(pthread_mutex_t *mutex);
/* Releases a lock the calling thread holds. Unlocking a mutex you do not own,
or one already unlocked, is undefined behaviour. Returns 0 or an error code. */
int pthread_mutex_unlock(pthread_mutex_t *mutex);
/* Releases resources of an initialized, currently-unlocked mutex. Destroying a
locked mutex is undefined behaviour. Required for dynamically-init'd mutexes. */
int pthread_mutex_destroy(pthread_mutex_t *mutex);
Key conventions: these functions return 0 on success and a positive error number on failure — they do not set errno, so check the return value directly (e.g. int rc = pthread_mutex_lock(&m); if (rc) { /* handle rc */ }). Compile and link with -lpthread (or -pthread). A statically-initialized mutex needs no destroy; a pthread_mutex_init'd one must be destroyed before its memory is freed, and only while unlocked.
A mutex (short for mutual exclusion lock) makes sure that only one thread at a time can enter a critical section — a region of code that touches shared data.
The pattern is always the same:
lock → modify shared state → unlock
pthread_mutex_t m = PTHREAD_MUTEX_INITIALIZER;
pthread_mutex_lock(&m);
/* … exactly one thread is here … */
pthread_mutex_unlock(&m);
While one thread holds the lock, any other thread that calls pthread_mutex_lock waits until the lock is released.
pthread_mutex_destroy when you are done with a mutex that you allocated on the heap.#include <pthread.h>
#include <stdio.h>
#include <stdlib.h>
#define ITERS 1000000
/* Shared state, guarded by one mutex. */
static long guarded_counter = 0;
static long naive_counter = 0;
static pthread_mutex_t counter_lock = PTHREAD_MUTEX_INITIALIZER;
/* Increments both counters: one under the lock, one without. */
static void *worker(void *arg) {
(void)arg;
for (int i = 0; i < ITERS; i++) {
/* Unsafe: a data race. Kept only to show the lost-update bug. */
naive_counter++;
/* Safe: lock -> modify -> unlock. Keep the region tiny. */
pthread_mutex_lock(&counter_lock);
guarded_counter++;
pthread_mutex_unlock(&counter_lock);
}
return NULL;
}
int main(void) {
pthread_t t[4];
for (int i = 0; i < 4; i++) {
if (pthread_create(&t[i], NULL, worker, NULL) != 0) {
perror("pthread_create");
return EXIT_FAILURE;
}
}
for (int i = 0; i < 4; i++)
pthread_join(t[i], NULL);
long expected = 4L * ITERS;
printf("expected = %ld\n", expected);
printf("guarded_counter = %ld (mutex-protected)\n", guarded_counter);
printf("naive_counter = %ld (unprotected; usually wrong)\n", naive_counter);
printf("guarded is correct: %s\n", guarded_counter == expected ? "yes" : "no");
/* Statically-initialized mutexes need no destroy, but it is legal. */
pthread_mutex_destroy(&counter_lock);
return 0;
}
#include <pthread.h> brings in pthread_mutex_t, the lock/unlock functions, and thread creation. Remember to link with -lpthread.static long guarded_counter / naive_counter: two shared counters. One will be updated under the lock, the other deliberately without, so you can see the difference in one run.static pthread_mutex_t counter_lock = PTHREAD_MUTEX_INITIALIZER;: the global lock, initialized statically. Because it is a file-scope object with a fixed lifetime, the static initializer is the right tool and no pthread_mutex_init call is needed.worker, naive_counter++; runs with no protection. It is a data race across four threads; increments will be lost.pthread_mutex_lock(&counter_lock); guarded_counter++; pthread_mutex_unlock(&counter_lock); are the whole point: the critical section is exactly one statement, held for the shortest possible time. Only one thread can be between the lock and unlock, so guarded_counter++ becomes effectively atomic.main, we spawn four threads with pthread_create, checking its return value (it returns an error code, not -1). Four threads make the race in naive_counter almost always visible.pthread_join waits for each thread to finish. Without joining, main could print and exit while workers are still running — and reading the counters then would itself be a race.expected = 4L * ITERS is the arithmetically correct total. The guarded_counter matches it every time; naive_counter almost never does.pthread_mutex_destroy(&counter_lock) is optional for a static mutex but shown for completeness; it must only ever be called when the mutex is unlocked and no thread will lock it again — here, after all joins.pthread_mutex_lock(&m); if (err) return -1; pthread_mutex_unlock(&m); — the error path never unlocks, so the next thread to lock blocks forever (a deadlock). Fixed: unlock on every path — pthread_mutex_lock(&m); if (err) { pthread_mutex_unlock(&m); return -1; } /* ... */ pthread_mutex_unlock(&m);, or route all exits through a single goto cleanup; label that unlocks once.lock(); balance -= amt; unlock(); while a reporter thread does printf("%ld", balance); with no lock. That read is a data race and may see a torn or stale value. Fixed: take the same lock around the read — lock(); long b = balance; unlock(); printf("%ld", b);.struct S *s = malloc(sizeof *s); s->m = PTHREAD_MUTEX_INITIALIZER; — assigning the initializer macro to a runtime field is not portable/valid. Fixed: pthread_mutex_init(&s->m, NULL); after allocation, and pthread_mutex_destroy(&s->m); before free(s);.m, then calls a helper that also locks m — a default (non-recursive) mutex deadlocks against itself. Fixed: either restructure so the helper assumes the lock is already held (document it as "caller must hold m"), or split the shared work into a lock-free inner function called from within the critical section.cc -std=c11 -fsanitize=thread -g prog.c -lpthread && ./a.out — TSan pinpoints the exact unsynchronized read/write pair and both stack traces.gdb -p <pid>, then thread apply all bt; a thread stuck in __lll_lock_wait / pthread_mutex_lock shows who is waiting, and you can often see the owner elsewhere holding it.-1, and do not set errno. Temporarily wrap calls: int rc = pthread_mutex_lock(&m); if (rc) fprintf(stderr, "lock: %s\n", strerror(rc));.sched_yield() calls between the load and store of a suspected unprotected variable to widen the race window.strace -f -e trace=futex ./a.out shows the futex syscalls the mutex uses under contention; a thread blocked forever in FUTEX_WAIT is a stuck lock.The whole point of a mutex is to eliminate data races, which the C11 memory model defines as undefined behaviour: two threads accessing the same non-atomic object where at least one writes, without synchronization, gives the compiler and CPU freedom to reorder, cache, or tear the access — results are not merely 'sometimes wrong' but formally undefined. A mutex establishes a happens-before relationship: everything one thread did before unlock is visible to the next thread after it locks, so no explicit memory barriers are needed. Hazards specific to mutexes: (1) use-after-destroy / destroy-while-locked — never pthread_mutex_destroy a locked mutex or one another thread may still use, and never free a struct containing a live mutex. (2) Guarding the wrong lock — protecting a variable with different mutexes in different places gives zero protection; one variable, one lock. (3) Recursive self-lock — a default mutex is not recursive; relocking it in the same thread is undefined/deadlock. (4) Uninitialized use — locking a mutex that was never initialized is undefined behaviour. For a plain counter, an alternative is _Atomic long with atomic_fetch_add, which needs no lock at all; reach for a mutex when you must update several related fields together as one indivisible transaction.
Mutexes appear everywhere shared mutable state meets threads: reference counts in shared_ptr-like schemes and in the CPython/PHP interpreters, connection and request counters in web servers (nginx worker stats, Apache scoreboards), buffer pools and lock-managers in databases (PostgreSQL, SQLite in multithread mode), free-lists inside malloc implementations, and the internal state of thread-safe logging libraries. Best practice: keep one lock per logical piece of state and document what each lock protects; keep critical sections tiny; never call into unknown code (callbacks, allocation, I/O) while holding a lock; establish and document a consistent lock-ordering when you need more than one lock (the topic of the next lesson on deadlocks); and prefer higher-level abstractions — atomics for single counters, read-write locks for read-mostly data, or a message-passing design that avoids sharing altogether — when they fit better than a plain mutex.
Take the naive counter from the example and add a second mutex-protected counter that four threads increment; confirm the protected one always equals threads * iterations while the naive one drifts low.
Convert a global pthread_mutex_t and the counter it guards into a heap-allocated struct counter { long value; pthread_mutex_t lock; }. Use pthread_mutex_init / pthread_mutex_destroy correctly and free the struct only after all threads join.
Write int withdraw(struct account *a, long amt) that locks, checks balance >= amt, subtracts, and unlocks — returning -1 (still unlocking!) when funds are insufficient. Verify with a goto cleanup version that every path unlocks exactly once.
Add a reporter thread that periodically prints the counter. First read it without the lock and observe (with TSan) that it is a data race; then fix it by reading a snapshot under the same lock and confirm TSan goes quiet.
Deliberately create a self-deadlock: have a locked function call a helper that locks the same default mutex, and observe the hang under gdb. Then fix it two ways — (a) refactor so the helper needs no lock, and (b) research and use a recursive mutex attribute — and explain the trade-off.
pthread_mutex_t enforces mutual exclusion: only one thread at a time can be inside the critical section it guards.counter++ safe.PTHREAD_MUTEX_INITIALIZER for globals, or dynamically with pthread_mutex_init for heap structs (and pthread_mutex_destroy before freeing, only when unlocked).errno; check the return value. Link with -lpthread.