Lecture 11 - Function Pointers, Concurrency and pthreads¶
Today has two halves that turn out to be the same idea. First: a function pointer - a variable that holds the address of a function, so you can pass "which code to run" around just like you pass any other value, and let a struct or a caller choose it. We use this to make Lecture 10's hash table customizable: the hash function becomes a setting, not a hardcoded choice. Second: threads. A program that does several things at once, sharing memory. The connection is not a coincidence - the way you hand a thread "which function to run" is exactly a function pointer, so today's first half is the key that unlocks the second. This is the start of a two-lecture unit: today we learn to create and join threads; next lecture we deal with the danger of sharing memory between them.
1. Function pointers¶
Every function you write lives somewhere in memory, at an address, just like
every variable does. A function pointer is a variable that stores that
address, so that calling fp(...) calls whichever function fp currently
points at.
Declaring one looks like a variable declaration wearing a disguise:
Read it from the inside out: fp is a pointer, to a function that takes
(int, int) and returns int. The parentheses around *fp are required
- without them, int *fp(int, int) would declare an ordinary function named
fp that returns int *, which is a completely different thing.
A function's name, used without calling it, decays to its address - the same "the name is the address" move you have already seen with arrays:
int add(int a, int b) { return a + b; }
int mul(int a, int b) { return a * b; }
int (*fp)(int, int);
fp = add; /* fp now points at add */
printf("%d\n", fp(3, 4)); /* calls add(3, 4) -> 7 */
fp = mul; /* re-point it at a different function */
printf("%d\n", fp(3, 4)); /* calls mul(3, 4) -> 12 */
fp(3, 4)and(*fp)(3, 4)are both valid and call the same thing; most C code writes the plainfp(3, 4)form.- The whole point is that
fpcan be reassigned - the same variable can driveadd's code at one moment andmul's at another. That is what lets you pass behavior, not just data, into a function or store it in a struct. - The declaration syntax is ugly enough that a
typedefis standard practice:
typedef int (*op_t)(int, int); /* op_t is now a type: "pointer to int(int,int)" */
op_t fp = add; /* much more readable */
Passing a function pointer as a parameter lets the caller choose the behavior a function uses internally - the classic callback pattern:
int apply(op_t op, int a, int b) {
return op(a, b); /* apply does not know or care which function this is */
}
int main(void) {
printf("%d\n", apply(add, 3, 4)); /* 7 - apply runs add */
printf("%d\n", apply(mul, 3, 4)); /* 12 - same apply, different behavior */
return 0;
}
apply never mentions add or mul by name - it just calls whatever op
points at. This is the whole idea we will reuse twice today: once to let a
hash table pick its hash function, and once - this is the payoff - because a
thread's "which code should run on this new thread" is answered with exactly
this mechanism.
In-class exercise: Part A, Exercise A1 (pen and paper) - trace a sequence of reassignments and calls through a function pointer and predict the output.
2. A customizable hash function¶
Recall Lecture 10's hash table: hash_table_t hardcoded one hash function,
hash_string (djb2), and ht_get/ht_put always called it by name. That was
fine when there was only one hash function in the picture, but a real library
should let the caller pick - maybe you want a different hash for short keys, or
want to compare two hash functions' bucket spread. A function pointer field
does exactly that.
typedef unsigned long (*hash_fn_t)(const char *); /* "a hash function": string in, unsigned long out */
struct hash_table {
entry_t **buckets;
int nbuckets;
hash_fn_t hash_fn; /* which hash function THIS table uses */
};
typedef struct hash_table hash_table_t;
ht_create now takes the hash function as a parameter and stores it in the
struct, instead of the struct assuming one:
hash_table_t *ht_create(int nbuckets, hash_fn_t hash_fn) {
hash_table_t *ht = malloc(sizeof(hash_table_t));
if (ht == NULL) {
return NULL;
}
ht->buckets = malloc(nbuckets * sizeof(entry_t *));
if (ht->buckets == NULL) {
free(ht);
return NULL;
}
ht->nbuckets = nbuckets;
ht->hash_fn = hash_fn; /* remember which function to call */
for (int i = 0; i < nbuckets; i++) {
ht->buckets[i] = NULL;
}
return ht;
}
And every place ht_get/ht_put/etc. used to call hash_string(key) directly,
they now call through the stored pointer:
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;
}
ht_put changes the same one line. Now two tables can use two different hash
functions with no other code change:
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 much weaker, toy 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); /* uses djb2 */
hash_table_t *ht2 = ht_create(8, sum_hash); /* uses sum_hash - same ht_put/ht_get! */
...
}
ht_getandht_putnever changed to know aboutsum_hash- they just callht->hash_fn(key), whichever function that happens to be. This is the same decouplingapplygave us above: the table's logic (bucket, chain, walk) is separate from its hash choice, and the two can vary independently.- Why this matters for today's connection to threads:
hash_fn_tis a type describing "a function with this signature," andht->hash_fn(key)is a call through a stored function pointer, filled in by whoever created the table. That is precisely the shape ofpthread_create's third argument, coming up next: "here is the function I want run," stored and later called by code that does not know its name in advance.
In-class exercise: Part B, Exercise B1 (on the computer) - add
hash_fnto the hash table, updateht_create/ht_get/ht_putto use it, and swap in a second hash function with no other code changes.
3. Why concurrency?¶
Every program we have written so far does exactly one thing at a time: one instruction, then the next, in one stream of control. Now we break that assumption. We want a program that does several things at once - a thread for each - sharing the same memory. Two different motivations, and it is worth telling them apart:
- Speed. If a computer has multiple cores, one thread can only ever use one of them. Splitting independent work - summing chunks of a huge array, say - across several threads lets them run in parallel, genuinely at the same time, and finish sooner.
- Overlap. Even on one core, a program that is waiting - for a file to load, a network reply, a user to type - can let another thread make progress while it waits, instead of blocking everything.
Both reasons come at a cost we will meet next lecture: threads that share memory can step on each other. Today we only build the mechanism; next time we handle the danger.
4. Process vs. thread¶
Every program you have run this quarter runs as a process - its own memory
space (code, globals, heap, stack), isolated from every other process. A
thread is a separate stream of execution inside one process. A process
always has at least one thread (the one running main); today we add more.
The key fact, and the one that drives everything else this week:
- Threads in the same process share the heap and global/static variables - any thread can read or write memory any other thread allocated.
- Each thread gets its own stack - its own local variables, its own in-progress function calls. A local variable in one thread is invisible to another.
process
+-----------------------------------------------------+
| code (shared) globals/heap (shared) |
| |
| thread 1 stack thread 2 stack thread 3 stack |
| (its own locals) (its own locals) (its own locals)|
+-----------------------------------------------------+
Shared heap and globals are exactly what makes threads useful - they can work on the same data without copying it - and exactly what makes them dangerous, which is next lecture's topic.
5. Creating a thread: pthread_create¶
C has no threads built into the language; we use a library, POSIX threads,
<pthread.h>. A thread is represented by a pthread_t handle, and we start one
with pthread_create:
#include <pthread.h>
int pthread_create(pthread_t *thread,
const pthread_attr_t *attr,
void *(*start_routine)(void *),
void *arg);
thread- out-parameter: where the new thread's handle gets written.attr- thread attributes; we always passNULLfor the defaults.start_routine- a function pointer, exactly like Section 1'sop_tand Section 2'shash_fn_t: the function the new thread will run. Its signature is fixed by the library: it takes onevoid *and returns avoid *.arg- the one value passed tostart_routine, as avoid *.- Return value -
0on success, a nonzero error code on failure. (Not-1anderrno, unlike most syscalls you have seen - check== 0.)
A minimal thread function and the call that starts it:
void *say_hello(void *arg) {
printf("hello from a thread!\n");
return NULL;
}
int main(void) {
pthread_t t;
pthread_create(&t, NULL, say_hello, NULL);
/* ... main keeps running here, concurrently with the new thread ... */
return 0;
}
say_helloruns concurrently withmain- as soon aspthread_createreturns, bothmainand the new thread are making progress, in an order the library does not promise. Ifmainreturns before the thread gets to run, the whole process can exit with the thread'sprintfnever happening. That is exactly the problem the next section solves.- Compiling: pthreads is a separate library, so link it with
-pthread:
In-class exercise: Part A, Exercise A2 (pen and paper) - predict which interleavings of two threads' output are possible.
6. Waiting for a thread: pthread_join¶
main cannot just hope the thread finishes in time. pthread_join blocks the
calling thread until the named thread is done:
thread- the handlepthread_createfilled in.retval- out-parameter: where the thread's return value (what itsstart_routinereturned) gets written. PassNULLif you do not need it.- Blocks until
threadhas run to completion, then returns0on success.
int main(void) {
pthread_t t;
pthread_create(&t, NULL, say_hello, NULL);
pthread_join(t, NULL); /* wait here until say_hello returns */
printf("main is done\n");
return 0;
}
- Now the
helloprint is guaranteed to happen beforemainexits -joinis the synchronization point. - Every thread you create should eventually be joined (or explicitly
detached, which we will not cover). An unjoined thread is like an unfreed
malloc: a resource leak, and if the thread outlivesmain's check for its result, you may lose data it computed.
In-class exercise: Part B, Exercise B2 (on the computer) - create one thread, have it compute something, join it, and use the result.
7. Passing data in and getting data out¶
start_routine takes exactly one void * in and returns exactly one void *
out. Real work needs more than that, so we package data behind the pointer.
In: a struct of arguments. If a thread needs several inputs, malloc a
small struct, fill it in, and pass its address as arg. Inside the thread
function, cast arg back to the real type first:
typedef struct {
int id; /* which thread this is */
int *data;
int start, end; /* the slice of data this thread owns */
} work_t;
void *worker(void *arg) {
work_t *w = (work_t *) arg; /* cast back from void * */
long sum = 0;
for (int i = w->start; i < w->end; i++) {
sum += w->data[i];
}
printf("thread %d summed [%d, %d): %ld\n", w->id, w->start, w->end, sum);
return NULL;
}
Out: a heap-allocated result. start_routine returns void *. To hand
back more than fits in a pointer, malloc the result inside the thread and
return its address; the joining thread frees it once it is done reading it.
void *square_it(void *arg) {
int n = *(int *) arg;
int *result = malloc(sizeof(int));
*result = n * n;
return result; /* caller reads *result, then frees it */
}
The classic bug: passing the address of a loop variable. If you launch
several threads in a loop and hand each one &i, every thread reads the
same i - and by the time a thread runs, the loop may have already moved
i on, or the loop may be over entirely. Do not do this:
/* BUGGY - do not copy this */
for (int i = 0; i < n; i++) {
pthread_create(&threads[i], NULL, worker, &i); /* all threads share one i! */
}
The fix is to give each thread its own copy, one you control the lifetime of -
an array of arguments, or one malloc per thread, indexed by i:
int *ids = malloc(n * sizeof(int));
for (int i = 0; i < n; i++) {
ids[i] = i; /* each thread gets its own slot */
pthread_create(&threads[i], NULL, worker, &ids[i]);
}
In-class exercise: Part B, Exercise B3 (on the computer) - pass each thread its own argument correctly and get a result back.
8. Many threads: create all, then join all¶
The pattern that shows up everywhere: launch n threads into an array of
handles, then join every one of them in a second loop. Do not join inside
the creation loop - that would wait for thread 0 before even starting thread 1,
throwing away the concurrency.
#define N 4
int main(void) {
pthread_t threads[N];
work_t args[N];
int data[8] = { 3, 1, 4, 1, 5, 9, 2, 6 };
for (int i = 0; i < N; i++) {
args[i] = (work_t){ .id = i, .data = data,
.start = i * 2, .end = i * 2 + 2 };
pthread_create(&threads[i], NULL, worker, &args[i]);
}
for (int i = 0; i < N; i++) {
pthread_join(threads[i], NULL); /* wait for every one before moving on */
}
printf("all threads done\n");
return 0;
}
- Each
workerhere reads its own disjoint slice[start, end)ofdataand writes only to its ownprintfline - no thread touches memory another thread is touching. That is what makes this example safe with no locks at all: the threads' reads and writes do not overlap. The moment two threads read and write the same memory, we need next lecture's tools. args[i]lives in an array inmain's stack frame for as long asmainis running, so it is safe to hand its address to a thread as long asmainjoins before the array goes out of scope.
In-class exercise: Part B, Exercise B4 (on the computer) - launch several threads that each own a disjoint slice of an array, join them all, and combine their results.
9. Wrap-up¶
- A function pointer stores the address of a function, so it can be
reassigned, passed as a parameter, or stored in a struct to let code choose
which function runs without hardcoding its name - the callback pattern.
Declare one with
ret_type (*name)(param_types), or hide the syntax behind atypedef. - We made Lecture 10's hash table customizable by storing a
hash_fn_t hash_fninhash_table_tand callinght->hash_fn(key)instead of a hardcodedhash_string(key)-ht_get/ht_putnever change, no matter which hash function is plugged in. - A thread is a separate stream of execution inside one process. Threads share the heap and globals but each has its own stack.
pthread_create(&t, NULL, fn, arg)starts a thread runningfn(arg), wherefnhas the fixed signaturevoid *fn(void *)- a function pointer, the same mechanism ashash_fn, now handed to the operating system instead of a hash table.pthread_join(t, &retval)blocks until threadtfinishes and hands back its return value; every thread you create should be joined.- Pass more than one value in through a
malloc'd struct; get more than one value out through amalloc'd result. Never hand a thread the address of a loop variable that keeps changing - give it its own copy. - The safe pattern for many threads: create all, then join all, each thread working on its own disjoint piece of data so no two threads read or write the same memory.
- Cliffhanger: that last rule - disjoint memory - was doing all the work keeping today's examples safe. Next lecture we let threads share and mutate the same memory (a counter, a shared table) and watch it go wrong: the race condition, and the tool that fixes it, the mutex.