Why threads need coordination

Real-World Analogy

Imagine two bank tellers (threads) sharing the same cash register drawer (shared variable). Teller A opens the drawer and reads the balance: $205. Before she can update it, Teller B opens the same drawer and also reads $205. They both process $100 and $10 withdrawals and write back $105 and $195 respectively. One update clobbers the other — the bank just lost $10. A mutex is the policy: only one teller may open the drawer at a time. The moment someone holds the drawer key, everyone else must wait.

When multiple threads access the same shared data and at least one access is a write, you have a potential race condition. The outcome depends on who runs first — a non-deterministic order decided by the OS scheduler. Race conditions are among the hardest bugs to reproduce and debug because they disappear when you add print statements (which change timing).

The simplest example is counter++. This looks atomic in C but the CPU breaks it into three machine steps: load, increment, store. If two threads interleave these steps, one increment gets lost. Run an experiment with 2 threads each doing 1,000,000 increments — you will observe a final count of ~1,010,000 instead of 2,000,000 due to races.

The region of code that accesses shared data is called the critical section. The rule is simple: at most one thread may execute inside a critical section at any time. This property is called mutual exclusion. The synchronization primitives — mutexes, semaphores — enforce this rule.

Race Conditions Are Hard To Find

A race condition may only manifest under specific timing conditions that change every run. A program can pass millions of test runs and still have a race. The only fix is correct synchronization — not "running it again and hoping it works."

Mutexes (mutual exclusion locks) are the primary tool. A mutex has two states: locked and unlocked. Before entering a critical section, a thread calls pthread_mutex_lock() — if the mutex is unlocked it acquires it; if locked it blocks until the owning thread releases it with pthread_mutex_unlock().

Semaphores generalize mutexes. Where a mutex allows exactly 1 thread in, a counting semaphore initialized to N allows N threads simultaneously. A binary semaphore (initialized to 1) behaves exactly like a mutex, but has the added flexibility that any thread can post it (not just the owner). They are ideal for producer-consumer signaling patterns.

Deadlock is the nightmare scenario: two or more threads each hold a lock and wait for a lock the other holds — permanently. Neither can make progress. The system freezes. Four conditions must all hold simultaneously for a deadlock to occur (and all four must be broken to prevent it).

Mental Model Summary

Race condition = unsynchronized access to shared data. Mutex = key that one thread holds at a time. Semaphore = counter that controls how many threads enter. Deadlock = threads waiting for each other forever. Critical section = the code you must protect.

Mutex, Semaphore & Reader-Writer Lock APIs

Mutex — pthread_mutex_t

/* ─── Static initialization (simplest) ─── */
pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;

/* ─── Dynamic initialization (when allocating at runtime) ─── */
pthread_mutex_t *lock = malloc(sizeof(pthread_mutex_t));
pthread_mutex_init(lock, NULL);   // NULL = default attributes

/* ─── Lock & unlock (the core pair) ─── */
pthread_mutex_lock(&lock);      // BLOCKS until lock available; then acquires it
/* --- critical section --- */
pthread_mutex_unlock(&lock);    // releases lock; wakes one waiting thread

/* ─── Non-blocking try-lock ─── */
if (pthread_mutex_trylock(&lock) == 0) {
    /* acquired the lock — do critical section */
    pthread_mutex_unlock(&lock);
} else {
    /* lock was busy — do something else */
}

/* ─── Cleanup (dynamic mutexes) ─── */
pthread_mutex_destroy(lock);
free(lock);
Rule: Only the thread that locked the mutex may unlock it. Unlocking a mutex you do not own is undefined behavior. Never call lock on a mutex you already hold in the same thread — that is a self-deadlock.

Semaphore — sem_t

#include <semaphore.h>

sem_t s;

/* sem_init(sem_ptr, pshared, initial_value) */
sem_init(&s, 0, 1);   // pshared=0 = between threads (not processes)
                     // initial_value=1 → binary semaphore (like mutex)
                     // initial_value=N → counting semaphore (N threads allowed in)
                     // initial_value=0 → used for signaling

sem_wait(&s);        // P operation: blocks while s==0; then decrements s
/* --- critical section or protected code --- */
sem_post(&s);        // V operation: increments s; wakes one waiting thread

sem_destroy(&s);     // cleanup when done
Key difference from mutex: Any thread can call sem_post() — there is no "owner". This makes semaphores ideal for producer-consumer signaling: the producer posts, the consumer waits.

Reader-Writer Lock — pthread_rwlock_t

pthread_rwlock_t rwlock = PTHREAD_RWLOCK_INITIALIZER;

/* Multiple readers allowed simultaneously */
pthread_rwlock_rdlock(&rwlock);   // acquire read lock (shared)
/* read shared data */
pthread_rwlock_unlock(&rwlock);

/* Only ONE writer at a time, no concurrent readers */
pthread_rwlock_wrlock(&rwlock);   // acquire write lock (exclusive)
/* modify shared data */
pthread_rwlock_unlock(&rwlock);
Use rwlocks when: reads are frequent and writes are rare. A read-heavy workload serializes unnecessarily under a plain mutex. With an rwlock, many readers proceed in parallel; a writer gets exclusive access only when needed.

The Four Deadlock Conditions (all must hold)

Condition 1
Mutual Exclusion
A resource (mutex) can be held by at most one thread at a time. This is necessary for correctness — you cannot remove it.
Condition 2
Hold and Wait
A thread holds one lock while waiting to acquire another. Preventable by acquiring all locks at once.
Condition 3
No Preemption
A lock can only be released by the thread holding it. Preventable by using trylock and backing off.
Condition 4
Circular Wait
Thread A waits for B's lock; Thread B waits for A's lock. Preventable by enforcing a global lock ordering (locking hierarchy).
Deadlock Prevention Rule

The most practical prevention: always acquire multiple mutexes in the same global order everywhere in your code. If every thread acquires lock A before lock B, circular wait is impossible. This is called a locking hierarchy.

Semaphore vs Mutex Comparison

PropertyMutexSemaphore
OwnershipYes — only owner can unlockNo — any thread can post
Initial valueAlways unlocked (0 or 1)Any non-negative integer N
Max concurrent threads1N (counting) or 1 (binary)
Use caseMutual exclusionMutual exclusion OR signaling
Signaling patternNot suitableInit to 0; producer posts, consumer waits
Header<pthread.h><semaphore.h>

Dining Philosophers Problem

The Dining Philosophers is the canonical example showing how semaphores solve circular-wait deadlock. N philosophers sit at a round table. Each needs 2 chopsticks (left and right) to eat, but each chopstick is shared with a neighbour.

Naive attempt: every philosopher picks up the left chopstick first, then tries for the right. If all N philosophers pick up their left chopstick simultaneously, everyone waits for the right — which their right-hand neighbour already holds. Circular wait. DEADLOCK.

Solution: add a counting semaphore limit initialized to N-1. Only N-1 philosophers are allowed to attempt eating at once. At least one philosopher can always hold both chopsticks and finish eating, breaking the circular wait.

sem_t limit;
sem_init(&limit, 0, N-1);   // at most N-1 philosophers eating simultaneously

void philosopher(int id) {
    while (1) {
        sem_wait(&limit);           // acquire permission slot (blocks if N-1 already eating)
        pthread_mutex_lock(&chopstick[id]);          // pick up left chopstick
        pthread_mutex_lock(&chopstick[(id+1) % N]);  // pick up right chopstick
        // eat...
        pthread_mutex_unlock(&chopstick[(id+1) % N]);
        pthread_mutex_unlock(&chopstick[id]);
        sem_post(&limit);           // release permission slot — wake a waiting philosopher
    }
}
Why it works: with at most N-1 philosophers holding a chopstick at once, at least one philosopher holds both chopsticks and can finish eating. That philosopher then releases both chopsticks and the semaphore slot, allowing a neighbour to proceed. The circular wait condition is broken.

Livelock, Starvation, and Fairness

Livelock — Active But Making No Progress

Livelock occurs when two (or more) threads are both running and actively responding to each other, but neither ever makes real progress. Unlike deadlock, the threads are not blocked — they keep executing. A classic example: two threads each back off when they detect contention, but they do so in perfect lockstep.

// Thread A: try → sees conflict → back off → try → sees conflict → back off...
// Thread B: try → sees conflict → back off → try → sees conflict → back off...
// Both threads are "active" but neither ever enters the critical section.
// Unlike deadlock: both are still running, just not progressing.
Resolution: add randomized backoff (each thread sleeps for a random duration before retrying) or use a global coordinator so threads do not mirror each other's behaviour.
Starvation — One Thread Never Gets In

Starvation is when one thread is perpetually denied access to a resource even though the system as a whole is making progress. Example: a low-priority thread repeatedly loses the lock to high-priority threads that always preempt it. The victim thread is technically "waiting correctly" but never gets scheduled for the critical section. Resolution: use fair queuing strategies such as ticket locks or FIFO mutexes that guarantee each waiter eventually gets access.

ConditionThread stateMaking progress?Resolution
Deadlock Blocked / waiting No Break one of the 4 Coffman conditions
Livelock Running No (busy loop) Randomized backoff or global coordinator
Starvation One blocked, others run Others yes, victim no Fair queuing (ticket locks, FIFO mutexes)
Race condition Running Yes, but incorrect Add synchronization

Lock Contention and Granularity

Lock granularity is the size of the data structure that a single lock protects. Choosing the right granularity is a key performance decision.

Coarse-grained locking uses one lock for an entire data structure (e.g., one mutex for a whole hash table). It is simple to reason about but causes high contention — threads queue up even for completely unrelated operations on different parts of the structure.

Fine-grained locking uses one lock per sub-resource (e.g., one mutex per hash table bucket). Multiple threads can access different buckets in parallel. The cost: more complexity and the risk of ABBA deadlock if locks are acquired in an inconsistent order.

// ── Coarse-grained: one mutex guards the whole table ──
pthread_mutex_lock(&table_lock);
table_insert(key, value);
pthread_mutex_unlock(&table_lock);

// ── Fine-grained: one mutex per bucket ──
int bucket = hash(key) % NUM_BUCKETS;
pthread_mutex_lock(&bucket_lock[bucket]);
bucket_insert(bucket, key, value);
pthread_mutex_unlock(&bucket_lock[bucket]);
Trade-off: coarse-grained = low complexity, high contention. Fine-grained = high concurrency, but must acquire locks in a consistent order to avoid ABBA deadlock. A good starting point: start coarse, profile, refine only the hot paths.

Monitors — Locks Do Not Compose

A monitor is a higher-level synchronization construct that bundles a shared data structure, a mutex for mutual exclusion, and condition variables for waiting into a single unit. The key motivation: "locks do not compose" — if two functions each correctly acquire their own lock, calling one from inside the other may deadlock, or leave invariants broken between the two operations.

Monitors solve this by making the locking an internal property of the object rather than the caller's responsibility. Callers never touch the mutex directly; every public operation is wrapped in a lock/unlock pair internally. In C we simulate monitors by convention — struct + mutex + condition variables all live together, and callers only call the provided wrapper functions.

typedef struct {
    int               value;
    pthread_mutex_t   lock;
    pthread_cond_t    not_empty;
} Monitor;

// All operations on Monitor go through wrapper functions that
// internally lock and unlock. Callers NEVER touch the mutex directly.
void monitor_put(Monitor *m, int v) {
    pthread_mutex_lock(&m->lock);
    m->value = v;
    pthread_cond_signal(&m->not_empty);
    pthread_mutex_unlock(&m->lock);
}
C++ note: C++ std::mutex + std::condition_variable inside a class with private data is a direct language-supported monitor. Java synchronized methods are monitors too. In C we achieve the same effect by convention and discipline.
Why "Locks Do Not Compose"

Suppose list_insert() and list_count() each correctly lock their own internal mutex. If you call list_insert() while already holding some external lock, and list_count() tries to acquire the same external lock, you get ABBA deadlock. The monitor pattern avoids this by keeping all locking internal and never requiring the caller to hold a lock before entering a monitor operation.

Waiting for a condition — pthread_cond_t

Mutexes let you exclude others from a critical section. But sometimes you need a thread to wait until something becomes true — e.g., a consumer waiting until the buffer is non-empty. Busy-waiting (spinning in a loop) wastes CPU. Condition variables let a thread sleep until signalled.

A condition variable is always used together with a mutex. The mutex protects the shared state; the condition variable provides the sleep/wake mechanism. The key insight: pthread_cond_wait atomically releases the mutex AND puts the thread to sleep — so no wakeup can be lost.

Always use a while loop, not if

Spurious wakeups can occur — the OS may wake a thread even without a signal. Always re-check the condition: while (!ready) pthread_cond_wait(&cv, &m); — never use if.

#include <pthread.h>

pthread_mutex_t m  = PTHREAD_MUTEX_INITIALIZER;
pthread_cond_t  cv = PTHREAD_COND_INITIALIZER;
int ready = 0;

/* Consumer — blocks until ready == 1 */
void *consumer(void *arg) {
    pthread_mutex_lock(&m);
    while (!ready)                          /* WHILE not if — spurious wakeups! */
        pthread_cond_wait(&cv, &m);   /* atomically: release m + sleep */
    /* --- woken up, mutex re-acquired, condition is true --- */
    pthread_mutex_unlock(&m);
    return NULL;
}

/* Producer — sets ready and wakes the consumer */
void *producer(void *arg) {
    pthread_mutex_lock(&m);
    ready = 1;
    pthread_cond_signal(&cv);            /* wake ONE waiting thread */
    pthread_mutex_unlock(&m);
    return NULL;
}
pthread_cond_signal vs pthread_cond_broadcast: signal wakes one waiting thread (use when one consumer is enough). broadcast wakes all waiting threads (use when the condition change affects all waiters, e.g., shutdown).

Condition Variable API — Quick Reference

FunctionWhat it does
pthread_cond_init(&cv, NULL)Initialize condition variable (or use PTHREAD_COND_INITIALIZER for static)
pthread_cond_wait(&cv, &mutex)Atomically release mutex + sleep; re-acquire mutex on wakeup
pthread_cond_signal(&cv)Wake one thread waiting on cv
pthread_cond_broadcast(&cv)Wake all threads waiting on cv
pthread_cond_destroy(&cv)Free resources when done

Bounded Buffer with Two Condition Variables

The classic producer-consumer with a bounded buffer uses two condition variables — one signalling "not empty" (wake consumer), one signalling "not full" (wake producer):

#include <pthread.h>
#define BUF 4

int buf[BUF], in=0, out=0, count=0;
pthread_mutex_t lock      = PTHREAD_MUTEX_INITIALIZER;
pthread_cond_t  not_full  = PTHREAD_COND_INITIALIZER;
pthread_cond_t  not_empty = PTHREAD_COND_INITIALIZER;

void produce(int item) {
    pthread_mutex_lock(&lock);
    while (count == BUF)
        pthread_cond_wait(&not_full, &lock);  /* sleep if full */
    buf[in % BUF] = item; in++; count++;
    pthread_cond_signal(&not_empty);           /* wake consumer */
    pthread_mutex_unlock(&lock);
}

int consume(void) {
    pthread_mutex_lock(&lock);
    while (count == 0)
        pthread_cond_wait(&not_empty, &lock); /* sleep if empty */
    int item = buf[out % BUF]; out++; count--;
    pthread_cond_signal(&not_full);           /* wake producer */
    pthread_mutex_unlock(&lock);
    return item;
}

Barriers — pthread_barrier_t

A barrier is a synchronization point where all N threads must arrive before any of them can continue. Use it when parallel work is divided into phases — all threads must finish phase 1 before any begins phase 2.

#include <pthread.h>

pthread_barrier_t barrier;

/* Initialize: count = number of threads that must arrive */
pthread_barrier_init(&barrier, NULL, 4);   /* wait for 4 threads */

/* Each thread calls this at the rendezvous point */
pthread_barrier_wait(&barrier);
/* All 4 threads blocked here until all 4 arrive; then all released */

pthread_barrier_destroy(&barrier);
When to use barrier vs cond: Barrier = "wait until ALL N threads reach this point." Condition variable = "wait until a specific condition becomes true." Barriers are for phased parallel algorithms; condition variables are for producer-consumer and event signaling.

Complete programs you can compile and run

Example 1 — Race condition then fix with mutex Week 10 Lecture
/* Compile: gcc -o race race.c -lpthread */
#include <stdio.h>
#include <pthread.h>

#define NUM_THREADS  2
#define ITERATIONS   1000000

long counter = 0;                        /* shared global — the danger zone */
pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;  /* declared globally */

/* BROKEN version — no synchronization */
void *broken_worker(void *arg) {
    for (long i = 0; i < ITERATIONS; i++) {
        counter++;   /* NOT ATOMIC: load, add 1, store — interleave = lost updates */
    }
    return NULL;
}

/* FIXED version — mutex guards the critical section */
void *fixed_worker(void *arg) {
    for (long i = 0; i < ITERATIONS; i++) {
        pthread_mutex_lock(&lock);   /* acquire lock — blocks if another thread holds it */
        counter++;                   /* critical section: only one thread at a time */
        pthread_mutex_unlock(&lock); /* release lock — next waiting thread proceeds */
    }
    return NULL;
}

int main(void) {
    pthread_t threads[NUM_THREADS];

    /* --- BROKEN run --- */
    counter = 0;
    for (int t = 0; t < NUM_THREADS; t++)
        pthread_create(&threads[t], NULL, broken_worker, NULL);
    for (int t = 0; t < NUM_THREADS; t++)
        pthread_join(threads[t], NULL);
    printf("BROKEN:  expected %ld, got %ld (lost ~%ld updates)\n",
           (long)(NUM_THREADS * ITERATIONS), counter,
           (long)(NUM_THREADS * ITERATIONS) - counter);

    /* --- FIXED run --- */
    counter = 0;
    for (int t = 0; t < NUM_THREADS; t++)
        pthread_create(&threads[t], NULL, fixed_worker, NULL);
    for (int t = 0; t < NUM_THREADS; t++)
        pthread_join(threads[t], NULL);
    printf("FIXED:   expected %ld, got %ld\n",
           (long)(NUM_THREADS * ITERATIONS), counter);

    pthread_mutex_destroy(&lock);
    return 0;
}
▶ Show sample output
Sample output (race results vary each run)
BROKEN: expected 2000000, got 1043271 (lost ~956729 updates)
FIXED: expected 2000000, got 2000000
Example 2 — Producer-Consumer with semaphores Week 11 Lecture
/* Compile: gcc -o prodcon prodcon.c -lpthread */
#include <stdio.h>
#include <pthread.h>
#include <semaphore.h>

#define BUFFER_SIZE  8
#define NUM_ITEMS    20

int buffer[BUFFER_SIZE];  /* circular buffer */
int head = 0, tail = 0;   /* consumer reads at head; producer writes at tail */

/* Three semaphores controlling the bounded buffer */
sem_t empty_slots;  /* counts how many slots are free (init = BUFFER_SIZE) */
sem_t full_slots;   /* counts how many slots have data (init = 0) */
pthread_mutex_t buf_lock = PTHREAD_MUTEX_INITIALIZER;  /* protects head/tail */

void *producer(void *arg) {
    for (int i = 0; i < NUM_ITEMS; i++) {
        int item = i * 10;               /* "produce" an item */

        sem_wait(&empty_slots);          /* block if buffer full */
        pthread_mutex_lock(&buf_lock);
        buffer[tail] = item;             /* write item into buffer */
        tail = (tail + 1) % BUFFER_SIZE; /* advance circular tail */
        printf("Produced: %d\n", item);
        pthread_mutex_unlock(&buf_lock);
        sem_post(&full_slots);           /* signal: one more item available */
    }
    return NULL;
}

void *consumer(void *arg) {
    for (int i = 0; i < NUM_ITEMS; i++) {
        sem_wait(&full_slots);           /* block if buffer empty */
        pthread_mutex_lock(&buf_lock);
        int item = buffer[head];         /* read item from buffer */
        head = (head + 1) % BUFFER_SIZE; /* advance circular head */
        printf("  Consumed: %d\n", item);
        pthread_mutex_unlock(&buf_lock);
        sem_post(&empty_slots);          /* signal: one more slot free */
    }
    return NULL;
}

int main(void) {
    /* Initialize semaphores */
    sem_init(&empty_slots, 0, BUFFER_SIZE); /* buffer starts fully empty */
    sem_init(&full_slots,  0, 0);           /* no items yet */

    pthread_t prod, cons;
    pthread_create(&prod, NULL, producer, NULL);
    pthread_create(&cons, NULL, consumer, NULL);
    pthread_join(prod, NULL);
    pthread_join(cons, NULL);

    sem_destroy(&empty_slots);
    sem_destroy(&full_slots);
    pthread_mutex_destroy(&buf_lock);
    return 0;
}
▶ Show sample output
Sample output (interleaving varies)
Produced: 0
Produced: 10
Consumed: 0
Produced: 20
Consumed: 10
Consumed: 20
...
Example 3 — ABBA deadlock demonstration and fix Week 10 Lecture — Deadlock Prevention
/* Compile: gcc -o deadlock deadlock.c -lpthread */
#include <stdio.h>
#include <pthread.h>
#include <unistd.h>

pthread_mutex_t lock_A = PTHREAD_MUTEX_INITIALIZER;
pthread_mutex_t lock_B = PTHREAD_MUTEX_INITIALIZER;

/* DEADLOCK version: Thread 0 acquires A then B, Thread 1 acquires B then A */
void *thread0_deadlock(void *arg) {
    pthread_mutex_lock(&lock_A);
    sleep(1);  /* force interleave: Thread 1 acquires B before Thread 0 asks for B */
    pthread_mutex_lock(&lock_B);  /* BLOCKS: Thread 1 holds B and waits for A */
    printf("Thread 0: inside critical section\n");
    pthread_mutex_unlock(&lock_B);
    pthread_mutex_unlock(&lock_A);
    return NULL;
}

void *thread1_deadlock(void *arg) {
    pthread_mutex_lock(&lock_B);
    sleep(1);  /* Thread 0 now holds A and waits for B — DEADLOCK */
    pthread_mutex_lock(&lock_A);  /* BLOCKS: Thread 0 holds A and waits for B */
    printf("Thread 1: inside critical section\n");
    pthread_mutex_unlock(&lock_A);
    pthread_mutex_unlock(&lock_B);
    return NULL;
}

/* FIXED version: BOTH threads acquire locks in the SAME ORDER (A then B) */
void *thread_fixed(void *arg) {
    long id = (long)arg;
    /* Locking hierarchy: always A before B — no circular wait possible */
    pthread_mutex_lock(&lock_A);
    pthread_mutex_lock(&lock_B);
    printf("Thread %ld: inside critical section (fixed)\n", id);
    pthread_mutex_unlock(&lock_B);  /* order of unlocking doesn't matter */
    pthread_mutex_unlock(&lock_A);
    return NULL;
}

int main(void) {
    pthread_t t0, t1;

    printf("--- Running FIXED version (locking hierarchy: A then B) ---\n");
    pthread_create(&t0, NULL, thread_fixed, (void*)0L);
    pthread_create(&t1, NULL, thread_fixed, (void*)1L);
    pthread_join(t0, NULL);
    pthread_join(t1, NULL);
    printf("Done — no deadlock!\n");

    /* NOTE: Running the deadlock version would hang indefinitely */
    return 0;
}
▶ Show output
Output
--- Running FIXED version (locking hierarchy: A then B) ---
Thread 0: inside critical section (fixed)
Thread 1: inside critical section (fixed)
Done — no deadlock!

Practice problems with solutions

P1 — What is the race condition? How many times does counter get incremented? Week 10 Lecture

Two threads each loop 1,000,000 times and increment the shared counter variable. Explain why the final value is often much less than 2,000,000, and describe exactly which machine instructions interleave to cause the problem.

long counter = 0;
void *worker(void *arg) {
    for (long i = 0; i < 1000000; i++)
        counter++;   /* is this one instruction? */
    return NULL;
}
Why it fails: counter++ looks like one operation in C, but CPUs that cannot modify memory in place (load/store architectures, including x86) break it into three steps:
1. Load: copy counter's value from memory into a register.
2. Increment: add 1 to the register.
3. Store: write the register value back to memory.

The race: Thread 1 loads counter = 7. Before Thread 1 stores 8, Thread 2 also loads 7. Thread 1 stores 8. Thread 2 stores 8. Net result: counter is 8, but both threads incremented — one increment is lost. With 2 threads doing 1,000,000 each, roughly 50% of increments can be lost, giving ~1,000,000 instead of 2,000,000. The exact loss depends on OS scheduling and is non-deterministic.
P2 — Spot the deadlock: what locks are acquired in what order? Week 10 Lecture — ABBA Deadlock

Explain why the following code can deadlock. Name the deadlock pattern and state which of the four necessary conditions are satisfied. Then fix it.

pthread_mutex_t m1, m2;

void *thread_A(void *arg) {
    pthread_mutex_lock(&m1);
    pthread_mutex_lock(&m2);   /* needs both m1 AND m2 */
    /* ... */
    pthread_mutex_unlock(&m2);
    pthread_mutex_unlock(&m1);
    return NULL;
}

void *thread_B(void *arg) {
    pthread_mutex_lock(&m2);   /* different order! */
    pthread_mutex_lock(&m1);
    /* ... */
    pthread_mutex_unlock(&m1);
    pthread_mutex_unlock(&m2);
    return NULL;
}
/* Fix: both threads acquire in the SAME order: m1 before m2 */
void *thread_B_fixed(void *arg) {
    pthread_mutex_lock(&m1);   /* same order as thread_A */
    pthread_mutex_lock(&m2);
    /* ... */
    pthread_mutex_unlock(&m2);
    pthread_mutex_unlock(&m1);
    return NULL;
}
Pattern: ABBA deadlock (named for the acquisition order A-then-B vs B-then-A).
Scenario: Thread A holds m1, waits for m2. Thread B holds m2, waits for m1. Neither can proceed.
Four conditions all satisfied: (1) Mutual exclusion — mutexes are exclusive. (2) Hold and wait — each thread holds one lock while waiting for another. (3) No preemption — neither thread gives up its lock. (4) Circular wait — A waits for B's lock; B waits for A's lock.
Fix: Enforce a locking hierarchy — always acquire m1 before m2. This breaks condition 4 (circular wait). Thread B must acquire m1 first; since m1 is held by A, B blocks without holding any lock, so A can proceed, release both locks, and then B can continue.
P3 — Binary semaphore vs. counting semaphore: which to use? Week 10 Lecture

You have a database connection pool with 5 connections. Multiple threads need connections. (a) Which type of semaphore should you use? (b) What should the initial value be? (c) What happens when all 5 connections are in use and a 6th thread requests one? Write the initialization code.

#include <semaphore.h>
sem_t conn_pool;

int main(void) {
    /* Counting semaphore, initial value = 5 (number of connections available) */
    sem_init(&conn_pool, 0, 5);

    /* In each thread that wants a connection: */
    sem_wait(&conn_pool);   /* decrement count; blocks if count==0 */
    /* ... use the connection ... */
    sem_post(&conn_pool);   /* increment count; wake a waiting thread */

    sem_destroy(&conn_pool);
    return 0;
}
(a) Counting semaphore — it tracks a resource with more than 1 unit (5 connections).
(b) Initial value = 5, because all 5 connections are initially free.
(c) When the 6th thread calls sem_wait(), the count is already 0. The thread blocks (sleeps) until one of the 5 threads using a connection calls sem_post(). A binary semaphore (init=1) would only allow 1 thread at a time — correct semantics for a mutex, wrong for a pool of N resources.
P4 — Spot the missing unlock bug Week 10 Lecture — Common mistakes

The function below has a synchronization bug. Find it, explain the consequence, and fix it.

pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;
int shared_val = 0;

int get_and_increment(void) {
    pthread_mutex_lock(&lock);
    if (shared_val < 0) {
        printf("Error: negative value!\n");
        return -1;   /* Bug is here */
    }
    int old = shared_val;
    shared_val++;
    pthread_mutex_unlock(&lock);
    return old;
}
int get_and_increment(void) {
    pthread_mutex_lock(&lock);
    if (shared_val < 0) {
        printf("Error: negative value!\n");
        pthread_mutex_unlock(&lock);  /* MUST unlock before returning */
        return -1;
    }
    int old = shared_val;
    shared_val++;
    pthread_mutex_unlock(&lock);
    return old;
}
Bug: The early return -1 path exits the function without calling pthread_mutex_unlock(). The mutex remains locked forever. Every subsequent thread that calls pthread_mutex_lock() will block permanently — a livelock-free deadlock of the entire mutex.
Consequence: All other threads that need this lock are stuck. The program freezes.
Lesson: Every code path that exits a critical section — including error returns and early returns — must call pthread_mutex_unlock(). In C++, RAII (lock_guard) handles this automatically. In C, you must audit every exit path manually.
P5 — Ping-pong with semaphores: enforce strict alternation Week 10 Lecture

Two threads must output ping and pong alternately forever (ping, pong, ping, pong, ...). Using only semaphores, write the initialization and the two thread functions. Hint: you need two semaphores.

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

sem_t ping_sem;  /* signals that it is ping's turn */
sem_t pong_sem;  /* signals that it is pong's turn */

void *ping_thread(void *arg) {
    for (int i = 0; i < 5; i++) {
        sem_wait(&ping_sem);     /* wait for permission to print ping */
        printf("ping\n");
        sem_post(&pong_sem);     /* give pong permission */
    }
    return NULL;
}

void *pong_thread(void *arg) {
    for (int i = 0; i < 5; i++) {
        sem_wait(&pong_sem);     /* wait for permission to print pong */
        printf("pong\n");
        sem_post(&ping_sem);     /* give ping permission for next round */
    }
    return NULL;
}

int main(void) {
    sem_init(&ping_sem, 0, 1);   /* ping goes first: start unlocked */
    sem_init(&pong_sem, 0, 0);   /* pong waits: start locked */
    pthread_t t1, t2;
    pthread_create(&t1, NULL, ping_thread, NULL);
    pthread_create(&t2, NULL, pong_thread, NULL);
    pthread_join(t1, NULL);
    pthread_join(t2, NULL);
    sem_destroy(&ping_sem);
    sem_destroy(&pong_sem);
    return 0;
}
Key insight: Initialize ping_sem = 1 so ping goes first. Initialize pong_sem = 0 so pong blocks until ping posts. Each thread waits on its own semaphore and posts the other's semaphore — this creates a strict handoff. This pattern (semaphore initialized to 0 used for signaling) is the canonical semaphore use beyond mutex replacement.
P6 — What is a mutex? (2024 Exam Q3) From: 2024 Final Exam

Which of the following best describes a mutex?

A) A synchronisation point where threads block until a certain number of threads have arrived
B) A specific critical section of code that prevents simultaneous access by multiple threads
C) A synchronization object used to allow multiple threads to serialize their access to shared data
D) A synchronisation point after which threads must continue execution sequentially

Answer: C — "A synchronization object used to allow multiple threads to serialize their access to shared data."

A mutex (mutual exclusion lock) is a synchronization object (a data structure with lock/unlock operations). It ensures that only one thread can hold it at any time. When a thread acquires the mutex, all other threads that try to acquire it are blocked until the holder releases it. This serializes access to shared data — threads take turns entering the critical section.

Why the other options are wrong:
• Option A describes a barrier — threads block until N threads arrive, then all proceed.
• Option B describes the critical section itself (the code region), not the locking mechanism.
• Option D describes a barrier variant — sequential execution after a synchronisation point.

Key exam distinction: A mutex is a primitive (the lock itself); the critical section is the code region that the mutex protects. Do not confuse the tool with the region it guards.
P7 — Four necessary conditions for deadlock (2024 Exam Q4) From: 2024 Final Exam

Which of the following are necessary conditions for a deadlock to occur? (Select all that apply.)

A) Did not use a monitor (monitors are more efficient than mutexes and semaphores)
B) Hold and wait — threads hold some resources and request other resources
C) Race conditions present
D) Mutual exclusion — a resource can only be assigned to at most one thread
E) Lack of dynamic memory
F) Circular wait — a cycle exists, each thread waits for a resource assigned to another
G) Memory leaks present
H) Missing compiler flags
I) No preemption — a resource assigned to a thread can only be released by that thread
J) Has zero edges in a task dependency graph

Correct answers: B, D, F, I — these are the four Coffman conditions. All four must hold simultaneously for a deadlock to occur.

1. Mutual Exclusion (D) — At least one resource must be held in a non-shareable mode; only one thread can use it at a time. This is inherent to mutexes and usually cannot be removed without changing correctness.

2. Hold and Wait (B) — A thread is holding at least one resource and waiting to acquire additional resources that are currently held by other threads. Prevention: require all resources to be requested at once (all-or-nothing acquisition).

3. No Preemption (I) — Resources cannot be forcibly taken away from a thread; they can only be released voluntarily by the thread holding them. Prevention: use trylock and back off if unable to acquire.

4. Circular Wait (F) — A set of threads {T1, T2, ..., Tn} exists such that T1 waits for a resource held by T2, T2 waits for T3, ..., and Tn waits for T1, forming a cycle. Prevention: enforce a global lock ordering (locking hierarchy).

All four must hold simultaneously. Breaking even one condition is sufficient to prevent deadlock. The most practical prevention in practice is eliminating circular wait via a locking hierarchy.

Why the other options are wrong: Options A, C, E, G, H, J are distractors — they describe other problems (race conditions, memory leaks, build issues) that are unrelated to the definition of deadlock.

Key concepts to memorize

Card 1 of 10
Question — click to flip
Answer
Click card to flip • Use buttons to navigate

Test your understanding

Topic 27 Quiz — Thread Synchronization Score: 0 / 6
1
What function do you call to acquire a mutex and block if it is already held?LO10
multiple choice
2
True or False: For a deadlock to occur, all four necessary conditions must hold simultaneously (mutual exclusion, hold-and-wait, no preemption, circular wait).LO10
true / false
3
What is the initial value of a binary semaphore used to implement mutual exclusion (like a mutex)?LO10
multiple choice
4
Fill in the blank: The deadlock prevention strategy that prevents circular wait is called a ______ (two words).LO10
fill in the blank
5
Spot the bug — what is wrong with this code?LO10
pthread_mutex_t m = PTHREAD_MUTEX_INITIALIZER;

void *worker(void *arg) {
    if (pthread_mutex_trylock(&m) != 0) {
        printf("Lock busy, doing fallback\n");
        pthread_mutex_unlock(&m);  /* line 6 */
    }
    /* ... */
    return NULL;
}
spot the bug — multiple choice
6
Which synchronization primitive allows multiple readers to hold the lock simultaneously, but grants exclusive access to a writer?LO10
multiple choice
0/6
Quiz complete!