Skip to content

Solutions: Lecture 11 In-Class Exercises

Solutions to the Lecture 11 in-class exercises. Try each exercise yourself before reading these. B1 needs no thread library; B2 onward 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 - Reasoning with function pointers and threads (pen and paper)

Exercise A1 - Trace the function pointer

int add(int a, int b) { return a + b; }
int sub(int a, int b) { return a - b; }
typedef int (*op_t)(int, int);

int main(void) {
    op_t f = add;
    printf("%d\n", f(10, 3));   /* f points at add -> 13 */
    f = sub;
    printf("%d\n", f(10, 3));   /* f now points at sub -> 7 */
    op_t g = f;
    printf("%d\n", g(1, 1));    /* g copied f's value (sub's address) -> 0 */
    return 0;
}
  1. The three lines print 13, 7, 0. f = add first, so f(10, 3) calls add(10, 3) = 13. Then f = sub, so f(10, 3) calls sub(10, 3) = 7. Then g is assigned f's current value, which at that point is sub's address, so g(1, 1) calls sub(1, 1) = 0.
  2. op_t g = f; copies the address f currently holds (at that moment, sub's address) into g - not any code, and not a link back to f itself. The two variables are independent after the copy: reassigning f = add; afterward changes only f. g keeps pointing at sub and calling g(...) still calls sub, exactly as it did right after the copy.

Exercise A2 - Which interleavings are possible?

  1. Every interleaving that keeps A before B and 1 before 2:
A B 1 2      A 1 B 2      A 1 2 B
1 2 A B      1 A 2 B      1 A B 2

(Six total - choosing which 2 of the 4 slots go to thread 1's lines, with the order within each thread fixed, gives C(4,2) = 6.)

  1. 1 B A 2 is illegal: 1 printed, and now B prints before A, which violates thread 1's own program order (A must come before B because that is the order those statements execute in that thread).

  2. Neither call guarantees a particular interleaving - the OS scheduler decides which thread runs when, and either could run first or be paused mid-way. The only ordering guarantee is pthread_join's: everything the joined thread did happens-before the join call returns. So main's "done" is guaranteed to print after both threads' output, but the four lines among themselves can appear in any of the orderings from part 1.

Exercise A3 - Spot the bug

  1. All four threads read from the same address, &i, because every pthread_create call in the loop passes the address of the one loop variable i - there is only one copy of i, not four. By the time each thread actually runs and dereferences arg, i may already have advanced (or the loop may have finished, leaving i == 4). In practice you will often see several threads print the same, wrong number, or values that do not match 0, 1, 2, 3 at all.

  2. Give each thread its own storage:

int main(void) {
    pthread_t threads[4];
    int ids[4];
    for (int i = 0; i < 4; i++) {
        ids[i] = i;
        pthread_create(&threads[i], NULL, print_id, &ids[i]);
    }
    for (int i = 0; i < 4; i++) {
        pthread_join(threads[i], NULL);
    }
    return 0;
}

Now each thread reads a distinct address (&ids[0], &ids[1], ...) that never changes after it is set, so every thread prints its own id.


Part B - At the keyboard

Exercise B1 - A swappable hash function

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

struct entry {
    char         *key;
    int           value;
    struct entry *next;
};
typedef struct entry entry_t;

typedef unsigned long (*hash_fn_t)(const char *);

struct hash_table {
    entry_t   **buckets;
    int         nbuckets;
    hash_fn_t   hash_fn;          /* the new field */
};
typedef struct hash_table hash_table_t;

hash_table_t *ht_create(int nbuckets, hash_fn_t hash_fn) {
    hash_table_t *ht = malloc(sizeof(hash_table_t));
    ht->buckets  = malloc(nbuckets * sizeof(entry_t *));
    ht->nbuckets = nbuckets;
    ht->hash_fn  = hash_fn;       /* remember which function this table uses */
    for (int i = 0; i < nbuckets; i++) {
        ht->buckets[i] = NULL;
    }
    return ht;
}

int ht_get(hash_table_t *ht, const char *key, int *out_value) {
    unsigned long i = ht->hash_fn(key) % ht->nbuckets;   /* was hash_string(key) */
    for (entry_t *e = ht->buckets[i]; e != NULL; e = e->next) {
        if (strcmp(e->key, key) == 0) {
            *out_value = e->value;
            return 1;
        }
    }
    return 0;
}

void ht_put(hash_table_t *ht, const char *key, int value) {
    unsigned long i = ht->hash_fn(key) % ht->nbuckets;   /* was hash_string(key) */
    for (entry_t *e = ht->buckets[i]; e != NULL; e = e->next) {
        if (strcmp(e->key, key) == 0) {
            e->value = value;
            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;
}

unsigned long hash_string(const char *key) {   /* djb2, from Lecture 10 */
    unsigned long h = 5381;
    for (int i = 0; key[i] != '\0'; i++) {
        h = h * 33 + (unsigned char) key[i];
    }
    return h;
}

unsigned long sum_hash(const char *key) {      /* a different, weaker hash */
    unsigned long h = 0;
    for (int i = 0; key[i] != '\0'; i++) {
        h += (unsigned char) key[i];
    }
    return h;
}

int main(void) {
    hash_table_t *ht1 = ht_create(8, hash_string);
    hash_table_t *ht2 = ht_create(8, sum_hash);

    ht_put(ht1, "cat", 3);
    ht_put(ht2, "cat", 3);

    int v1 = 0, v2 = 0;
    ht_get(ht1, "cat", &v1);
    ht_get(ht2, "cat", &v2);
    printf("ht1 (hash_string) cat -> %d\n", v1);
    printf("ht2 (sum_hash)    cat -> %d\n", v2);
    return 0;
}
  • Two functions changed: ht_create (new parameter, one new assignment) and the one line each in ht_get/ht_put that computes the bucket index. Nothing about the chain walk, the strcmp, the insert, or the struct layout of entry_t had to change.
  • hash_string and sum_hash themselves did not have to change (they were already plain functions matching hash_fn_t's signature), and neither did main's calls to ht_get/ht_put - they call the same two functions regardless of which table (and therefore which hash function) they are operating on. That is the payoff of storing the choice as a function pointer in the struct rather than hardcoding a name: the "which hash function" decision moved from being baked into ht_get/ht_put's source code to being a value passed in at ht_create time.

Exercise B2 - Create one thread, join it, use its result

void *sum_to_n(void *arg) {
    int n = *(int *) arg;
    long total = 0;
    for (int i = 1; i <= n; i++) {
        total += i;
    }
    long *result = malloc(sizeof(long));
    if (result != NULL) {
        *result = total;
    }
    return result;
}

int main(void) {
    int n = 100;
    pthread_t t;
    pthread_create(&t, NULL, sum_to_n, &n);

    long *result = NULL;
    pthread_join(t, (void **) &result);
    printf("n = %d -> thread computed %ld\n", n, *result);
    free(result);
    return 0;
}
  • n is a local in main's stack frame, but that is safe here because main joins the thread before n goes out of scope - the thread only reads it while main is still blocked waiting.
  • The result must be malloc'd because a local long inside sum_to_n would live on that thread's stack, which is gone the moment the function returns - returning its address would be exactly last month's "returning the address of a local" bug. The caller (main) takes ownership of the malloc'd result and frees it.

Exercise B3 - Give each thread its own argument

void *square_it(void *arg) {
    int i = *(int *) arg;
    printf("thread %d: %d*%d = %d\n", i, i, i, i * i);
    return NULL;
}

int main(void) {
    pthread_t threads[5];
    int ids[5];

    for (int i = 0; i < 5; i++) {
        ids[i] = i;
        pthread_create(&threads[i], NULL, square_it, &ids[i]);
    }
    for (int i = 0; i < 5; i++) {
        pthread_join(threads[i], NULL);
    }
    return 0;
}
  • Each thread gets the address of its own slot in ids, so each reads a value that is fixed before any thread could possibly run. All 5 threads are created before any is joined, so they genuinely run concurrently; the printed order across threads is not guaranteed (and does not need to be).

Exercise B4 - Parallel array sum, one slice per thread

typedef struct {
    int *data;
    int  start, end;
    long partial;
} chunk_t;

void *sum_chunk(void *arg) {
    chunk_t *c = (chunk_t *) arg;
    c->partial = 0;
    for (int i = c->start; i < c->end; i++) {
        c->partial += c->data[i];
    }
    return NULL;
}

#define N 100
#define NTHREADS 4

int main(void) {
    int data[N];
    for (int i = 0; i < N; i++) {
        data[i] = i + 1;              /* 1..100, so the total is easy to check */
    }

    pthread_t threads[NTHREADS];
    chunk_t   chunks[NTHREADS];
    int       slice = N / NTHREADS;

    for (int t = 0; t < NTHREADS; t++) {
        chunks[t].data  = data;
        chunks[t].start = t * slice;
        chunks[t].end   = (t == NTHREADS - 1) ? N : (t + 1) * slice;
        pthread_create(&threads[t], NULL, sum_chunk, &chunks[t]);
    }
    for (int t = 0; t < NTHREADS; t++) {
        pthread_join(threads[t], NULL);
    }

    long total = 0;
    for (int t = 0; t < NTHREADS; t++) {
        printf("thread %d slice [%d, %d): partial sum %ld\n",
               t, chunks[t].start, chunks[t].end, chunks[t].partial);
        total += chunks[t].partial;
    }
    printf("total: %ld\n", total);    /* 5050, same as B2's n = 100 */
    return 0;
}
  • The last slice takes end = N rather than (t+1)*slice so that if N does not divide evenly by NTHREADS, no elements are dropped.
  • It is safe for main to read every chunks[t].partial right after the join loop, with no lock, because pthread_join already guarantees each thread has finished - including its write to .partial - before join returns. By the time all four joins are done, nothing is still running that could change those values, and each thread only ever wrote its own chunks[t], never another thread's. No overlap in what is written, and no thread is still writing when main reads - that is why this needs no synchronization.

Stretch - take it further

Time it

#include <time.h>

clock_t start = clock();
/* ... run the parallel version ... */
clock_t end = clock();
printf("parallel: %f sec\n", (double)(end - start) / CLOCKS_PER_SEC);

Time the same way around a plain sequential loop over the whole array. With a small array, thread-creation overhead can make the "parallel" version slower than the sequential one - parallelism only pays off once the work per thread is large enough to be worth the overhead of creating and joining it. With far more threads than CPU cores, adding threads eventually stops helping (or starts hurting) because the cores are already saturated and the extra threads just add scheduling overhead.

A results array instead of a struct field

void *square_into(void *arg) {
    /* arg points at one element of a small argument struct holding
       {int i; int *results;} so the thread knows both its index and
       where to write */
    return NULL;   /* body left as in B3's square_it, writing results[i] = i*i */
}

Each thread writes only results[i] for its own i, so, exactly as in B4, different threads never write the same array slot - main can safely read the whole results array once every thread has been joined.

A compare function pointer

typedef struct { char name[32]; int age; } person_t;
typedef int (*cmp_t)(const void *, const void *);

int by_age(const void *a, const void *b) {
    return ((const person_t *) a)->age - ((const person_t *) b)->age;
}

int by_name(const void *a, const void *b) {
    return strcmp(((const person_t *) a)->name, ((const person_t *) b)->name);
}

int find_max(person_t *people, int n, cmp_t cmp) {
    int max_i = 0;
    for (int i = 1; i < n; i++) {
        if (cmp(&people[i], &people[max_i]) > 0) {
            max_i = i;
        }
    }
    return max_i;
}
  • find_max(people, n, by_age) finds the oldest person; find_max(people, n, by_name) finds the alphabetically last, with find_max itself never changing - exactly B1's swappable-hash-function idea, applied to comparison instead of hashing. This is also precisely how the standard library's qsort(base, n, size, cmp) lets you sort by any criterion.