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:
and compiles with:
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;
}
- The three lines print
13,7,0.f = addfirst, sof(10, 3)callsadd(10, 3) = 13. Thenf = sub, sof(10, 3)callssub(10, 3) = 7. Thengis assignedf's current value, which at that point issub's address, sog(1, 1)callssub(1, 1) = 0. op_t g = f;copies the addressfcurrently holds (at that moment,sub's address) intog- not any code, and not a link back tofitself. The two variables are independent after the copy: reassigningf = add;afterward changes onlyf.gkeeps pointing atsuband callingg(...)still callssub, exactly as it did right after the copy.
Exercise A2 - Which interleavings are possible?¶
- Every interleaving that keeps
AbeforeBand1before2:
(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 B A 2is illegal:1printed, and nowBprints beforeA, which violates thread 1's own program order (Amust come beforeBbecause that is the order those statements execute in that thread). -
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 thejoincall returns. Somain'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¶
-
All four threads read from the same address,
&i, because everypthread_createcall in the loop passes the address of the one loop variablei- there is only one copy ofi, not four. By the time each thread actually runs and dereferencesarg,imay already have advanced (or the loop may have finished, leavingi == 4). In practice you will often see several threads print the same, wrong number, or values that do not match0, 1, 2, 3at all. -
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 inht_get/ht_putthat computes the bucket index. Nothing about the chain walk, thestrcmp, the insert, or the struct layout ofentry_thad to change. hash_stringandsum_hashthemselves did not have to change (they were already plain functions matchinghash_fn_t's signature), and neither didmain's calls toht_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 intoht_get/ht_put's source code to being a value passed in atht_createtime.
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;
}
nis a local inmain's stack frame, but that is safe here becausemainjoins the thread beforengoes out of scope - the thread only reads it whilemainis still blocked waiting.- The result must be
malloc'd because a locallonginsidesum_to_nwould 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 themalloc'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 = Nrather than(t+1)*sliceso that ifNdoes not divide evenly byNTHREADS, no elements are dropped. - It is safe for
mainto read everychunks[t].partialright after the join loop, with no lock, becausepthread_joinalready guarantees each thread has finished - including its write to.partial- beforejoinreturns. By the time all four joins are done, nothing is still running that could change those values, and each thread only ever wrote its ownchunks[t], never another thread's. No overlap in what is written, and no thread is still writing whenmainreads - 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, withfind_maxitself never changing - exactly B1's swappable-hash-function idea, applied to comparison instead of hashing. This is also precisely how the standard library'sqsort(base, n, size, cmp)lets you sort by any criterion.