Skip to content

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:

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

and compiles with:

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

Part A - Tracing a race (pen and paper)

Exercise A1 - Lose an update by hand

seats_left starts at 2.

  1. 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.

  1. 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.

  1. 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_left only 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 exactly 200000 every 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_order reads 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?" - total is a global; busy, value, order_id, base, i are 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_a lock lock_b before lock_a, mirroring its "natural" source-then-destination order. If a transfer_a_to_b thread and a transfer_b_to_a thread both start at nearly the same moment, the first could hold lock_a while waiting for lock_b, and the second could hold lock_b while waiting for lock_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_a first and lock_b second. No thread can ever hold lock_b while waiting on lock_a, so the deadlock cannot occur.
  • stock_a + stock_b stays 200 throughout, 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 return paths unlock before returning - a common bug is adding an early return to a locked function and forgetting the matching unlock on 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.