Synchronization: Races, Mutexes, Semaphores and Condition Variables

A race condition occurs when the result depends on the timing of threads accessing shared data.

Concept

The Critical Section Problem A race condition occurs when the result depends on the timing of threads accessing shared data. counter++ is really *load, add, store*; two threads can load the same old value, and one update is lost.

A critical section is code that accesses shared state. A correct solution must provide:

Mutual exclusion — at most one thread inside.

Progress — if nobody is inside, a waiting thread can enter.

Bounded waiting — no thread waits forever (no starvation).

Correct locks need atomic instructions: test-and-set, compare-and-swap (x86 lock cmpxchg), fetch-and-add (lock xadd), or load-linked/store-conditional (ARM ldxr/stxr, RISC-V lr/sc). Disabling interrupts works only on a single core (used in tiny embedded kernels).

Busy-wait on an atomic flag; for very short sections, mostly in kernels

Lock owned by one thread; waiters sleep. pthread_mutex_t (Linux: fast user-space path, futex system call only when contended)

Counter with wait (P, decrement or block) and post (V, increment/wake); counting semaphores limit access to N resources; a binary semaphore acts like a lock without ownership Condition variable

Wait (atomically releasing a mutex) until a condition becomes true; always used with a mutex and a while loop Read-write lock Many readers or one writer All threads wait until everyone arrives

Lock-free single-variable updates (stdatomic.h, std::atomic)

Language-level combination of mutex + condition variables (Java synchronized, C# lock)

Producer–consumer (bounded buffer): semaphores empty (free slots) and full (filled slots) + a mutex.

Readers–writers: read-write locks; beware writer starvation.

Dining philosophers: illustrates deadlock and starvation (Module 16).

Why It Matters

Races are the hardest bugs to reproduce: they depend on timing, disappear under a debugger and appear in production. Every multithreaded program, driver and RTOS application relies on these primitives.

Syntax

pthread_mutex_lock(&m); /* critical section */ pthread_mutex_unlock(&m);
sem_wait(&s); sem_post(&s);
pthread_mutex_lock(&m); while (!ready) pthread_cond_wait(&cv, &m); pthread_mutex_unlock(&m);
atomic_fetch_add(&counter, 1);

Example

15.1 A lost-update race, fixed with a mutex and with an atomic

Compiled with -O0 so the compiler keeps the separate load, add and store of counter++ (with optimization the race still exists, but the compiler may merge the loop into one addition, hiding it).

15.2 Producer–consumer with counting semaphores

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

enum { THREADS = 4, ITER = 1000000 };
long plain = 0;
long locked = 0;
atomic_long atomic_counter = 0;
pthread_mutex_t m = PTHREAD_MUTEX_INITIALIZER;

static void *racy(void *a)     { for (int i = 0; i < ITER; i++) plain++; return a; }       /* data race! */
static void *with_mutex(void *a) { for (int i = 0; i < ITER; i++) { pthread_mutex_lock(&m); locked++; pthread_mutex_unlock(&m); } return a; }
static void *with_atomic(void *a){ for (int i = 0; i < ITER; i++) atomic_fetch_add(&atomic_counter, 1); return a; }

static double run(void *(*fn)(void *))
{
    struct timespec a, b;
    pthread_t t[THREADS];
    clock_gettime(CLOCK_MONOTONIC, &a);
    for (int i = 0; i < THREADS; i++) pthread_create(&t[i], NULL, fn, NULL);
    for (int i = 0; i < THREADS; i++) pthread_join(t[i], NULL);
    clock_gettime(CLOCK_MONOTONIC, &b);
    return (b.tv_sec - a.tv_sec) * 1e3 + (b.tv_nsec - a.tv_nsec) / 1e6;
}

int main(void)
{
    double t1 = run(racy), t2 = run(with_mutex), t3 = run(with_atomic);
    printf("expected           %d\n", THREADS * ITER);
    printf("no synchronization %ld   (%.0f ms)\n", plain, t1);
    
…
#include <stdio.h>
#include <pthread.h>
#include <semaphore.h>

/* Bounded buffer (producer-consumer) with two counting semaphores and a mutex */
enum { SIZE = 4, ITEMS = 12 };
int buffer[SIZE], in = 0, out = 0;
sem_t empty_slots, full_slots;
pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;
int max_fill = 0, fill = 0;

static void *producer(void *arg)
{
    (void)arg;
    for (int i = 1; i <= ITEMS; i++) {
        sem_wait(&empty_slots);                 /* blocks when the buffer is full */
        pthread_mutex_lock(&lock);
        buffer[in] = i; in = (in + 1) % SIZE;
        if (++fill > max_fill) max_fill = fill;
        pthread_mutex_unlock(&lock);
        sem_post(&full_slots);
    }
    return NULL;
}

static void *consumer(void *arg)
{
    long sum = 0;
    for (int i = 0; i < ITEMS; i++) {
        sem_wait(&full_slots);                  /* blocks when the buffer is empty */
        pthread_mutex_lock(&lock);
        int item = buffer[out]; out = (out + 1) % SIZE; fill--;
        pthread_mutex_unlock(&lock);
        sem_post(&empty_slots);
        sum += item;
    }
    *(long *)arg = sum;
    return NULL;
}

int main(void)
{
    sem_init(&empty_slots, 0, SIZE);
    sem_ini
…

Expected Result

Four threads each incremented the counter a million times, but without synchronization more than half of the updates were lost (the exact number differs on every run). The mutex and the atomic both give the correct 4 000 000; the atomic is cheaper because it needs no lock, but both are far slower than the (wrong) unsynchronized version because the cache line holding the counter must bounce between cores (Module 26).

The semaphores made the producer block when all 4 slots were full and the consumer block when the buffer was empty; every item was consumed exactly once.

expected           4000000
no synchronization 1436019   (12 ms)
mutex              4000000   (175 ms)
atomic             4000000   (64 ms)
consumed 12 items, sum 78 (expected 78), buffer never held more than 4 of 4 slots

Common Mistakes

Protecting writes but not reads of shared data.

Using if instead of while around pthread_cond_wait (spurious wake-ups).

Forgetting to unlock on an error path (use a single exit or RAII in C++).

Believing volatile makes code thread-safe (it only prevents some compiler optimizations; it gives no atomicity or ordering between threads).

Holding a lock while doing slow I/O.

Best Practices

Keep critical sections short; lock data, not code.

Prefer higher-level constructs (queues, channels, thread pools) to hand-rolled locking.

Use atomics for simple counters and flags; use ThreadSanitizer (-fsanitize=thread) to find races.

Document which lock protects which data.

Interview Relevance

Very high: "what is a race condition?", "mutex vs semaphore?", "binary semaphore vs mutex?", "what is a spinlock and when is it appropriate?", "solve producer-consumer", "why must condition waits be in a loop?", "what is compare-and-swap?".

Practice Questions

Run 15.1 with -fsanitize=thread and read the report.

Rewrite the bounded buffer with a mutex and two condition variables.

Implement a spinlock with atomic_flag_test_and_set and compare it with the mutex.

Solve readers–writers with pthread_rwlock_t and measure with 8 readers and 1 writer.

Quick Quiz

Q1. (Multiple choice) Which primitive allows up to N threads into a section? A) Mutex B) Counting semaphore C) Spinlock D) Barrier Answer: B.

Q2. (Predict the output) 4 threads × 1 000 000 unsynchronized counter++ at -O0 — exactly 4 000 000?

Answer: Almost never; the run shown lost more than half the increments (verified).

Q3. (True/False) Declaring the counter volatile fixes the race.

Answer: False. volatile doesn't make read-modify-write atomic.

Q4. (Find the bug) pthread_mutex_lock(&m); if (queue_empty()) pthread_cond_wait(&cv, &m); item = pop();

Answer: Use while, not if: after a spurious wake-up or another consumer taking the item, the queue may still be empty.

Coding Challenge

Implement a thread-safe counter with pthread_mutex_t, increment it from 8 threads × 100 000 times, and print the final value.

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

typedef struct { pthread_mutex_t lock; long value; } Counter;
static Counter c = {PTHREAD_MUTEX_INITIALIZER, 0};

static void *work(void *arg)
{
    (void)arg;
    for (int i = 0; i < 100000; i++) {
        pthread_mutex_lock(&c.lock);
        c.value++;
        pthread_mutex_unlock(&c.lock);
    }
    return NULL;
}

int main(void)
{
    pthread_t t[8];
    for (int i = 0; i < 8; i++) pthread_create(&t[i], NULL, work, NULL);
    for (int i = 0; i < 8; i++) pthread_join(t[i], NULL);
    printf("final value %ld (expected 800000)\n", c.value);
    return 0;
}