Skip to content

Lecture 12 - Race Conditions, Mutexes and Locks, Synchronization

Last lecture's examples all shared one property that kept them safe: every thread touched its own slice of memory, and nothing else. Today we break that rule on purpose - several threads reading and writing the same memory - and watch a program compute the wrong answer despite every line of code looking correct. Then we fix it with the tool built for exactly this problem: the mutex.

1. A shared counter

The simplest possible shared-state program: N threads, each incrementing one global counter a million times.

#include <pthread.h>
#include <stdio.h>

long counter = 0;                       /* shared by every thread */

void *increment(void *arg) {
    for (int i = 0; i < 1000000; i++) {
        counter++;                      /* looks harmless... */
    }
    return NULL;
}

int main(void) {
    pthread_t t1, t2;
    pthread_create(&t1, NULL, increment, NULL);
    pthread_create(&t2, NULL, increment, NULL);
    pthread_join(t1, NULL);
    pthread_join(t2, NULL);
    printf("counter = %ld\n", counter);  /* expected: 2000000 */
    return 0;
}

Run it. The answer is not always 2000000 - it varies from run to run, and is usually less. Nothing crashed, nothing printed an error - the program just silently computed the wrong number. This is a race condition: the result depends on the unpredictable timing ("race") between threads.

In-class exercise: Part A, Exercise A1 (pen and paper) - trace two threads' interleaved reads and writes and see exactly how an increment gets lost.


2. Why: counter++ is not one step

counter++ looks like a single operation in C, but the CPU does it in three steps:

1. LOAD  counter into a register     (read)
2. ADD   1 to that register          (modify)
3. STORE the register back to counter (write)

This is a classic read-modify-write. If a thread's three steps run without interruption, it works. But another thread's LOAD can happen between another thread's LOAD and STORE - reading the old value before the first thread has written its new one:

counter starts at 41

thread A: LOAD counter -> 41
                                thread B: LOAD counter -> 41
thread A: ADD 1        -> 42
thread A: STORE 42     -> counter is now 42
                                thread B: ADD 1        -> 42   (B's register still says 41!)
                                thread B: STORE 42     -> counter is now 42

Two increments happened, but counter only went up by one - thread B's increment was lost because it read the counter's value before thread A wrote its update. Multiply this by two million increments across two threads and you get exactly the shortfall you saw when you ran the program.

  • This code, counter++ here, is called a critical section: a piece of code that reads and writes shared state, where an interleaved execution from another thread can produce a wrong result. Recognizing critical sections is the whole skill of this lecture.
  • The bug is nondeterministic - it may not show up every run, or every million iterations, which is what makes race conditions so much harder to debug than a crash. A program can "work" in testing and fail in production simply because the timing was different that day.

3. The fix: mutual exclusion

We need to guarantee that once a thread starts a critical section, no other thread can enter it until the first is done - the three LOAD/ADD/STORE steps run as if they were one indivisible step. This guarantee is called mutual exclusion, and the tool that provides it is a mutex (mutual exclusion lock).

A mutex has two states, locked and unlocked, and one rule: at most one thread can hold it locked at a time. A thread that tries to lock an already-locked mutex blocks - it waits - until the thread holding it unlocks it.

#include <pthread.h>

pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;   /* start unlocked */

pthread_mutex_lock(&lock);
/* critical section: only one thread at a time is ever in here */
pthread_mutex_unlock(&lock);
  • PTHREAD_MUTEX_INITIALIZER is a static initializer for a global or file-scope mutex. A mutex allocated at run time (say, inside a struct on the heap) is initialized with pthread_mutex_init(&lock, NULL) and cleaned up with pthread_mutex_destroy(&lock) when done.
  • pthread_mutex_lock blocks if another thread already holds the lock, and returns once it has acquired it. pthread_mutex_unlock releases it, letting a waiting thread (if any) proceed.
  • Every lock needs exactly one matching unlock on every path through the code - the mutex equivalent of "every malloc needs exactly one free." A critical section that returns early or hits an error path without unlocking leaves the mutex locked forever, and every other thread that tries to lock it blocks forever too.

Fixing the counter:

long counter = 0;
pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;

void *increment(void *arg) {
    for (int i = 0; i < 1000000; i++) {
        pthread_mutex_lock(&lock);
        counter++;                      /* now genuinely one thread at a time */
        pthread_mutex_unlock(&lock);
    }
    return NULL;
}

Now counter reliably ends at exactly 2000000, every run.

In-class exercise: Part B, Exercise B1 (on the computer) - reproduce the race, then fix it with a mutex and confirm the count is exact every time.


4. Keep the critical section small

A mutex trades correctness for concurrency: while one thread holds the lock, every other thread that wants it is stalled, doing nothing. So lock only the shared memory access itself, not any surrounding work that does not touch shared state:

/* BAD: work that does not need protection is still serialized */
pthread_mutex_lock(&lock);
int square = compute_expensive_thing(i);   /* no shared state touched here! */
total += square;
pthread_mutex_unlock(&lock);

/* GOOD: only the shared write is protected */
int square = compute_expensive_thing(i);
pthread_mutex_lock(&lock);
total += square;
pthread_mutex_unlock(&lock);

The second version lets every thread compute its own square fully in parallel, and only briefly serializes the one line that actually touches shared memory. This is the general design principle: identify exactly which memory is shared and mutated, and protect only that.

In-class exercise: Part B, Exercise B2 (on the computer) - protect a shared running total for several worker threads, keeping the locked region minimal.


5. Deadlock: when locking goes wrong

A single mutex is straightforward, but a thread that needs to hold two locks at once can deadlock: two threads each hold one lock and wait forever for the other.

pthread_mutex_t lock_a = PTHREAD_MUTEX_INITIALIZER;
pthread_mutex_t lock_b = PTHREAD_MUTEX_INITIALIZER;

/* thread 1 */                          /* thread 2 */
pthread_mutex_lock(&lock_a);            pthread_mutex_lock(&lock_b);
pthread_mutex_lock(&lock_b);  /* stuck */ pthread_mutex_lock(&lock_a);  /* stuck */

If thread 1 grabs lock_a and thread 2 grabs lock_b at nearly the same moment, thread 1 then blocks waiting for lock_b (held by thread 2), and thread 2 blocks waiting for lock_a (held by thread 1). Neither can ever proceed - a deadlock. Unlike a race condition, a deadlock is not "wrong output," it is the program freezing entirely.

The fix: a consistent lock order. If every thread that needs both locks always acquires them in the same order (say, always lock_a before lock_b), the situation above cannot happen - whichever thread gets lock_a first will also get lock_b next, since no thread ever holds lock_b while waiting for lock_a.

/* both threads agree: always lock_a, then lock_b */
pthread_mutex_lock(&lock_a);
pthread_mutex_lock(&lock_b);
/* ... critical section touching both ... */
pthread_mutex_unlock(&lock_b);
pthread_mutex_unlock(&lock_a);          /* unlock in reverse order */

This matters concretely whenever an operation touches two shared structures at once - moving an item between two shared queues, transferring a value between two shared totals. Whatever order you pick, every thread must use the same order, every time.

In-class exercise: Part B, Exercise B3 (on the computer) - implement an operation that must lock two shared counters at once, using a consistent lock order to avoid deadlock.


6. A glance beyond today

Mutexes are the workhorse, but not the only synchronization tool - two are worth knowing by name, even without building them today:

  • Condition variables let a thread sleep until another thread signals that some condition became true (e.g., "the queue is no longer empty"), instead of wasting CPU checking in a loop.
  • Atomic operations (<stdatomic.h>) do a single read-modify-write, like our counter++, as one indivisible hardware instruction - no lock needed for that one operation, at the cost of only working for simple cases like a single counter.

Both are natural next steps once a program's synchronization needs grow past "protect one shared variable with one mutex," which is as far as we go today.


7. Wrap-up

  • A race condition happens when multiple threads read and write shared memory and the result depends on unpredictable timing - counter++ is not one step but a load, add, store, and another thread's interleaved access can lose an update.
  • A block of code that touches shared state unsafely is a critical section. A mutex (pthread_mutex_t, lock/unlock) guarantees only one thread is ever inside a critical section at a time - mutual exclusion.
  • Keep critical sections small: lock only the shared-memory access itself, not surrounding work that does not touch shared state, so threads still run in parallel everywhere else.
  • Locking two mutexes at once can deadlock if different threads acquire them in different orders. The fix is a single, consistent lock order shared by every thread.
  • Condition variables and atomics are the next tools once one mutex on one variable is not enough - named today, built later if you need them.
  • This closes the concurrency unit. The core skill for the rest of the course: before you write to memory another thread might touch, ask "is this a critical section?" - and if so, lock it.