Solutions: Lecture 12 In-Class Exercises¶
Solutions to the Lecture 12 in-class exercises. Try each exercise yourself before reading these. Every keyboard solution starts from:
and compiles with:
Part A - Tracing a race (pen and paper)¶
Exercise A1 - Lose an update by hand¶
seats_left starts at 2.
- A correct interleaving - each thread's LOAD sees the other's completed STORE, never an in-progress value:
A: LOAD seats_left -> 2
A: MODIFY -> 1
A: STORE 1 -> seats_left = 1
B: LOAD seats_left -> 1
B: MODIFY -> 0
B: STORE 0 -> seats_left = 0
Fully sequential (no actual overlap) always gives the right answer - 0.
- An interleaving that loses an update:
A: LOAD seats_left -> 2
B: LOAD seats_left -> 2 <-- B reads before A's STORE happens
A: MODIFY -> 1
A: STORE 1 -> seats_left = 1
B: MODIFY -> 1
B: STORE 1 -> seats_left = 1 (should be 0!)
The lost update is B's LOAD: it reads 2 (the value before A's decrement),
not 1 (the value it should have seen after A finished). B's STORE then
overwrites A's work with a value computed from stale data.
- Both threads' code ran with no error and both "believe" they sold a seat, but
only one decrement actually took effect - the booking system has now sold
two seats while
seats_leftonly reflects one sale. Concretely: the system could double-book the same seat, or (in an inventory system) sell more items than actually exist in stock.
Part B - At the keyboard¶
Exercise B1 - Fix a ticket-booth race¶
long tickets_sold = 0;
pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;
int use_lock; /* toggle to compare both versions */
void *sell_tickets(void *arg) {
for (int i = 0; i < 50000; i++) {
if (use_lock) {
pthread_mutex_lock(&lock);
tickets_sold++;
pthread_mutex_unlock(&lock);
} else {
tickets_sold++; /* unprotected: racy */
}
}
return NULL;
}
int main(void) {
for (int trial = 0; trial < 2; trial++) {
use_lock = trial;
tickets_sold = 0;
pthread_t threads[4];
for (int i = 0; i < 4; i++) {
pthread_create(&threads[i], NULL, sell_tickets, NULL);
}
for (int i = 0; i < 4; i++) {
pthread_join(threads[i], NULL);
}
printf("%s tickets_sold = %ld\n", use_lock ? "(locked) " : "(unlocked)", tickets_sold);
}
return 0;
}
- Run the unlocked trial a few times (rerun the whole program) and the printed
count varies and is usually less than
200000. The locked trial prints exactly200000every time. - The unprotected version sometimes gets the correct answer anyway because a lost update requires two threads' LOAD/MODIFY/STORE sequences to actually overlap in time - with fast increments and few threads, that overlap does not always happen. That is exactly what makes race conditions dangerous: the bug is real on every run, but it is only visible on some runs, so testing a handful of times is not proof of correctness. It is never safe to ship code with an unprotected critical section just because it "usually works."
Exercise B2 - A shared total, minimally locked¶
long total = 0;
pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;
int process_order(int order_id) {
long busy = 0;
for (int i = 0; i < 2000000; i++) { /* stand-in for slow work */
busy += i % 7;
}
return order_id * 10 + (int)(busy % 3); /* some value derived from the order */
}
void *worker(void *arg) {
int base = *(int *) arg;
for (int i = 0; i < 10; i++) {
int value = process_order(base + i); /* slow, outside the lock */
pthread_mutex_lock(&lock);
total += value; /* the only shared-state line */
pthread_mutex_unlock(&lock);
}
return NULL;
}
int main(void) {
pthread_t threads[4];
int bases[4] = { 0, 10, 20, 30 };
for (int i = 0; i < 4; i++) {
pthread_create(&threads[i], NULL, worker, &bases[i]);
}
for (int i = 0; i < 4; i++) {
pthread_join(threads[i], NULL);
}
printf("total = %ld\n", total);
return 0;
}
- The critical section is exactly one line,
total += value;- that is the only place any thread reads or writes memory another thread can also touch.process_orderreads and writes only its own locals (busy, its parameter), so it needs no protection and can run fully in parallel across all 4 threads. - We decided that line was the critical section, and no more, by asking "which
memory here is shared, and which is private to this thread's stack?" -
totalis a global;busy,value,order_id,base,iare all locals, one copy per thread.
Exercise B3 - Transfer between two shared counters without deadlock¶
int stock_a = 100, stock_b = 100;
pthread_mutex_t lock_a = PTHREAD_MUTEX_INITIALIZER;
pthread_mutex_t lock_b = PTHREAD_MUTEX_INITIALIZER;
/* both directions lock in the SAME order: lock_a, then lock_b */
void transfer_a_to_b(int amount) {
pthread_mutex_lock(&lock_a);
pthread_mutex_lock(&lock_b);
stock_a -= amount;
stock_b += amount;
pthread_mutex_unlock(&lock_b);
pthread_mutex_unlock(&lock_a);
}
void transfer_b_to_a(int amount) {
pthread_mutex_lock(&lock_a); /* still lock_a first, even though b is the source */
pthread_mutex_lock(&lock_b);
stock_b -= amount;
stock_a += amount;
pthread_mutex_unlock(&lock_b);
pthread_mutex_unlock(&lock_a);
}
void *mover(void *arg) {
int direction = *(int *) arg;
for (int i = 0; i < 1000; i++) {
if (direction == 0) {
transfer_a_to_b(1);
} else {
transfer_b_to_a(1);
}
}
return NULL;
}
int main(void) {
pthread_t threads[4];
int dirs[4] = { 0, 1, 0, 1 }; /* two threads each direction */
for (int i = 0; i < 4; i++) {
pthread_create(&threads[i], NULL, mover, &dirs[i]);
}
for (int i = 0; i < 4; i++) {
pthread_join(threads[i], NULL);
}
printf("stock_a = %d, stock_b = %d, total = %d\n",
stock_a, stock_b, stock_a + stock_b); /* total always 200 */
return 0;
}
- The unsafe version (not shown as working code) would have
transfer_b_to_alocklock_bbeforelock_a, mirroring its "natural" source-then-destination order. If atransfer_a_to_bthread and atransfer_b_to_athread both start at nearly the same moment, the first could holdlock_awhile waiting forlock_b, and the second could holdlock_bwhile waiting forlock_a- deadlock, and the program hangs forever. - The fix keeps the source/destination arithmetic direction-dependent but makes
the locking order direction-independent: every call, regardless of which
way stock is moving, locks
lock_afirst andlock_bsecond. No thread can ever holdlock_bwhile waiting onlock_a, so the deadlock cannot occur. stock_a + stock_bstays200throughout, confirming no updates were lost and the program terminates instead of hanging.
Stretch - take it further¶
Time the over-locked vs. minimally-locked version¶
Locking around the whole worker loop body (including process_order) forces
every thread to wait for every other thread's slow work to finish before it can
even start its own - the runtime barely changes as you add threads, because at
most one thread is ever doing real work at a time. Locking around only
total += value (B2's version) lets all 4 threads run process_order
simultaneously and only briefly serializes the cheap addition, so the runtime
drops close to 1/4 with 4 threads (until you run out of cores).
A shared hash table needs one lock too¶
struct hash_table {
entry_t **buckets;
int nbuckets;
pthread_mutex_t lock; /* one lock for the whole table */
};
void ht_put(hash_table_t *ht, const char *key, int value) {
pthread_mutex_lock(&ht->lock);
unsigned long i = hash_string(key) % ht->nbuckets;
for (entry_t *e = ht->buckets[i]; e != NULL; e = e->next) {
if (strcmp(e->key, key) == 0) {
e->value = value;
pthread_mutex_unlock(&ht->lock);
return;
}
}
entry_t *e = malloc(sizeof(entry_t));
e->key = malloc(strlen(key) + 1);
strcpy(e->key, key);
e->value = value;
e->next = ht->buckets[i];
ht->buckets[i] = e;
pthread_mutex_unlock(&ht->lock);
}
- Note both
returnpaths unlock before returning - a common bug is adding an early return to a locked function and forgetting the matchingunlockon that path, leaving the mutex locked forever. - One table-wide lock is correct but serializes every
ht_put/ht_get, even ones touching different buckets that could not actually collide - a finer-grained design (one mutex per bucket) would let unrelated buckets be modified concurrently, at the cost of more bookkeeping.