Skip to content

Handout: Lecture 12 In-Class Exercises

Part A is pen-and-paper: trace an interleaving by hand and see exactly how an update gets lost. Part B is at the keyboard: reproduce a race condition, fix it with a mutex, keep a critical section small, and avoid deadlock with a consistent lock order. None of these repeats an earlier exercise; they drill today's moves - recognizing a critical section, pthread_mutex_lock/unlock, and locking two mutexes safely.

Try each one yourself first; we will discuss in class, and the solutions are in a separate document afterward. Compile with warnings on and link the thread library:

clang -Wall -Wextra -std=c17 -pthread myprog.c -o myprog

Reminders that will keep you out of trouble today:

  • A critical section is code that reads and writes memory shared by more than one thread. x++ on a shared x is three steps (load, add, store), not one.
  • pthread_mutex_lock(&m) blocks until the mutex is free, then holds it; pthread_mutex_unlock(&m) releases it. Every lock needs exactly one unlock on every path.
  • Keep the locked region small - only the shared-memory access, not surrounding work that touches nothing shared.
  • Locking two mutexes at once can deadlock. Fix: every thread acquires them in the same order, every time.
  • pthread_mutex_t m = PTHREAD_MUTEX_INITIALIZER; for a global mutex - no separate init call needed.

Set up

mkdir -p ~/cmsc14300/lec12
cd ~/cmsc14300/lec12

Start every exercise from these includes:

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

Part A - Tracing a race (pen and paper)

Exercise A1 - Lose an update by hand

A shared variable seats_left starts at 2. Two threads each try to sell one seat by running:

int local = seats_left;   /* LOAD */
local = local - 1;        /* MODIFY */
seats_left = local;       /* STORE */

(This is seats_left-- written out as its three steps.)

  1. Write out one interleaving of thread A's and thread B's three steps (A's three steps stay in order, B's three steps stay in order, but the two threads' steps can interleave with each other) that leaves seats_left at the arithmetically correct value, 0.
  2. Write out a different interleaving that leaves seats_left at 1 instead of 0, and point at the exact step where an update got lost - which thread's LOAD read a value that was already stale?
  3. In your part 2 interleaving, both threads believe they successfully sold a seat (both ran to completion with no error). What real-world problem would this cause for a booking system?

Part B - At the keyboard

Exercise B1 - Fix a ticket-booth race

A shared long tickets_sold starts at 0. Launch 4 threads that each "sell" 50,000 tickets by incrementing tickets_sold in a loop, with no protection. Run it a few times and watch the final count vary and fall short of 200000. Then add a mutex and confirm the count is exactly 200000 on every run.

long tickets_sold = 0;   /* unprotected first, then protected with a mutex */
void *sell_tickets(void *arg);
(no lock)   run 1: tickets_sold = 187342
(no lock)   run 2: tickets_sold = 199981
(w/ lock)   every run: tickets_sold = 200000
  • First version: tickets_sold++; inside the loop, no mutex. Run the program two or three times to see the count change between runs.
  • Second version: declare pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;, and wrap just the increment: pthread_mutex_lock(&lock); tickets_sold++; pthread_mutex_unlock(&lock);.
  • Check your understanding: why does the unprotected version sometimes get the correct answer anyway? Does that make it safe to ship?

Exercise B2 - A shared total, minimally locked

4 worker threads each "process" 10 orders. Processing one order is slow (loop doing busy work, or a for loop summing something pointless a few thousand times) and touches no shared state; only adding the order's value to a shared total needs protection. Write it so the slow part runs unlocked and only the addition is inside the critical section.

long total = 0;
pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;
int process_order(int order_id);   /* slow, returns a value, touches nothing shared */
void *worker(void *arg);
4 threads, 10 orders each -> total = <sum of all 40 orders' values>
  • Inside the worker's loop: int value = process_order(id); outside the lock, then pthread_mutex_lock(&lock); total += value; pthread_mutex_unlock(&lock); - just the one line inside.
  • Compare (informally, by eye or with the stretch's timing) against a version that locks around the whole process_order call too - the minimally-locked version should let the 4 threads' slow work overlap, while the over-locked version serializes it.
  • Check your understanding: which specific line in worker is the critical section, and how did you decide it was that line and not the whole loop body?

Exercise B3 - Transfer between two shared counters without deadlock

Two shared warehouse stock counters, stock_a and stock_b, each start at 100, each with its own mutex. Write transfer(int amount), called concurrently by several threads with different (possibly opposite-direction) amounts, that moves amount units from stock_a to stock_b - but do it in a way that never deadlocks, even if some threads transfer a to b and others transfer b to a.

int stock_a = 100, stock_b = 100;
pthread_mutex_t lock_a = PTHREAD_MUTEX_INITIALIZER;
pthread_mutex_t lock_b = PTHREAD_MUTEX_INITIALIZER;
void transfer_a_to_b(int amount);   /* locks both, moves amount, unlocks both */
  • The unsafe version locks in "the order this call happens to need": a thread moving a -> b locks lock_a then lock_b; a thread moving b -> a locks lock_b then lock_a. Two such threads running at once can deadlock exactly like the notes' lock_a/lock_b example.
  • The fix: always lock in the same fixed order regardless of transfer direction - for example, always lock_a before lock_b, no matter which counter is the source. Only the arithmetic inside changes with direction; the locking order does not.
  • Launch several threads calling transfer_a_to_b and a version transferring the other way, all touching the same two mutexes, and confirm the program finishes (does not hang) and stock_a + stock_b is still 200 at the end.

Stretch - take it further

Time the over-locked vs. minimally-locked version

Time B2's two versions (lock around only total += value vs. lock around the whole loop body including process_order) with a process_order slow enough to matter (a few million pointless loop iterations). Confirm that the minimally-locked version finishes faster with more threads, while the over-locked version's runtime barely improves no matter how many threads you add - it has been serialized back into a single-threaded program in all but name.

A shared hash table needs one lock too

Take ht_put from Lecture 10 and make it safe to call from multiple threads at once by adding a single pthread_mutex_t to the hash_table_t struct and locking around the whole insert-or-update body. (A single table-wide lock is the simplest correct answer; finer-grained locking, one mutex per bucket, is possible but not required here.)