Systems programming · lesson 09

Mutexes and race conditions

Two threads incrementing a shared counter seems harmless. But <code>counter++</code> is three machine instructions, and the scheduler can interrupt between any two of them. The result is wrong in a way that only shows up under load, on specific hardware, or never in the debugger.

in progress
12 min

Why counter++ is not atomic

counter++ looks like one operation in C. At the machine level it's three: load the value from memory into a register, increment the register, store it back. If two threads execute this sequence and the scheduler interleaves them, both threads can read the same value, both increment it to the same result, and both store the same value — so two increments produce a net increment of one.

c
int counter = 0; // Thread A and Thread B both run this: void *increment(void *arg) { for (int i = 0; i < 100000; i++) counter++; // NOT atomic — data race return NULL; } // Expected: 200000. Actual: somewhere below 200000, varies every run.
🚫
A data race is undefined behavior in C11. If two threads access the same memory location concurrently and at least one access is a write, and there is no synchronization between them, the behavior is undefined. Not just "wrong result" — the compiler is allowed to make assumptions that completely break your program. Sanitize with -fsanitize=thread to catch races at runtime.

Mutexes

A mutex (mutual exclusion lock) ensures that only one thread executes the protected region at a time. pthread_mutex_lock acquires the lock — blocking if another thread holds it. pthread_mutex_unlock releases it. The region between lock and unlock is the critical section.

c
#include <pthread.h> int counter = 0; pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER; void *increment(void *arg) { for (int i = 0; i < 100000; i++) { pthread_mutex_lock(&lock); counter++; // now atomic with respect to other threads pthread_mutex_unlock(&lock); } return NULL; } // Result: always exactly 200000.

Deadlock

Deadlock occurs when two (or more) threads are each waiting for a lock held by the other, so neither can proceed. It happens when locks are acquired in inconsistent orders.

c
// Thread A: Thread B: pthread_mutex_lock(&A); pthread_mutex_lock(&B); pthread_mutex_lock(&B); pthread_mutex_lock(&A); // A holds A, waits for B B holds B, waits for A → deadlock

The fix: always acquire locks in the same global order. If every thread that needs both A and B always locks A before B, deadlock is impossible.

⚠️
Keep critical sections short. The bigger the critical section, the longer other threads wait. Don't hold a lock while doing I/O, sleeping, or calling unknown code. Lock, do the minimal work on shared state, unlock.

Condition variables

A mutex alone only prevents concurrent access. Sometimes a thread needs to wait for a condition — not just a lock. A condition variable lets a thread sleep (releasing the mutex atomically) until another thread signals that something changed.

c
pthread_mutex_t m = PTHREAD_MUTEX_INITIALIZER; pthread_cond_t cond = PTHREAD_COND_INITIALIZER; int ready = 0; // consumer thread: wait until ready pthread_mutex_lock(&m); while (!ready) // always loop — spurious wakeups pthread_cond_wait(&cond, &m); // atomically releases m, sleeps // ... consume ... pthread_mutex_unlock(&m); // producer thread: signal pthread_mutex_lock(&m); ready = 1; pthread_cond_signal(&cond); pthread_mutex_unlock(&m);
one-line takeaway

Any unsynchronized read-modify-write on shared data is a race — use a mutex to make the critical section atomic, and always acquire multiple locks in a consistent order to prevent deadlock.