Thread Synchronization
Race conditions, mutexes, deadlocks, semaphores, and reader-writer locks — the toolkit for making concurrent programs correct.
Why threads need coordination
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.
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).
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);
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
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);
The Four Deadlock Conditions (all must hold)
trylock and backing off.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
| Property | Mutex | Semaphore |
|---|---|---|
| Ownership | Yes — only owner can unlock | No — any thread can post |
| Initial value | Always unlocked (0 or 1) | Any non-negative integer N |
| Max concurrent threads | 1 | N (counting) or 1 (binary) |
| Use case | Mutual exclusion | Mutual exclusion OR signaling |
| Signaling pattern | Not suitable | Init 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 } }
Livelock, Starvation, and Fairness
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.
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.
| Condition | Thread state | Making 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]);
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); }
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.
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.
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; }
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
| Function | What 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(¬_full, &lock); /* sleep if full */ buf[in % BUF] = item; in++; count++; pthread_cond_signal(¬_empty); /* wake consumer */ pthread_mutex_unlock(&lock); } int consume(void) { pthread_mutex_lock(&lock); while (count == 0) pthread_cond_wait(¬_empty, &lock); /* sleep if empty */ int item = buf[out % BUF]; out++; count--; pthread_cond_signal(¬_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);
Complete programs you can compile and run
/* 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;
}
FIXED: expected 2000000, got 2000000
/* 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;
}
Produced: 10
Consumed: 0
Produced: 20
Consumed: 10
Consumed: 20
...
/* 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;
}
Thread 0: inside critical section (fixed)
Thread 1: inside critical section (fixed)
Done — no deadlock!
Practice problems with solutions
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;
}
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.
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;
}
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.
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;
}
(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.
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;
}
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.
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;
}
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.
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
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.
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
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
Test your understanding
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;
}