Skip to content

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:

int (*fp)(int, int);

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 plain fp(3, 4) form.
  • The whole point is that fp can be reassigned - the same variable can drive add's code at one moment and mul'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 typedef is 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_get and ht_put never changed to know about sum_hash - they just call ht->hash_fn(key), whichever function that happens to be. This is the same decoupling apply gave 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_t is a type describing "a function with this signature," and ht->hash_fn(key) is a call through a stored function pointer, filled in by whoever created the table. That is precisely the shape of pthread_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_fn to the hash table, update ht_create/ht_get/ht_put to 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 pass NULL for the defaults.
  • start_routine - a function pointer, exactly like Section 1's op_t and Section 2's hash_fn_t: the function the new thread will run. Its signature is fixed by the library: it takes one void * and returns a void *.
  • arg - the one value passed to start_routine, as a void *.
  • Return value - 0 on success, a nonzero error code on failure. (Not -1 and errno, 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_hello runs concurrently with main - as soon as pthread_create returns, both main and the new thread are making progress, in an order the library does not promise. If main returns before the thread gets to run, the whole process can exit with the thread's printf never happening. That is exactly the problem the next section solves.
  • Compiling: pthreads is a separate library, so link it with -pthread:
clang -Wall -Wextra -std=c17 -pthread myprog.c -o myprog

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:

int pthread_join(pthread_t thread, void **retval);
  • thread - the handle pthread_create filled in.
  • retval - out-parameter: where the thread's return value (what its start_routine returned) gets written. Pass NULL if you do not need it.
  • Blocks until thread has run to completion, then returns 0 on 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 hello print is guaranteed to happen before main exits - join is 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 outlives main'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 worker here reads its own disjoint slice [start, end) of data and writes only to its own printf line - 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 in main's stack frame for as long as main is running, so it is safe to hand its address to a thread as long as main joins 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 a typedef.
  • We made Lecture 10's hash table customizable by storing a hash_fn_t hash_fn in hash_table_t and calling ht->hash_fn(key) instead of a hardcoded hash_string(key) - ht_get/ht_put never 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 running fn(arg), where fn has the fixed signature void *fn(void *) - a function pointer, the same mechanism as hash_fn, now handed to the operating system instead of a hash table.
  • pthread_join(t, &retval) blocks until thread t finishes 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 a malloc'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.