COMP 3150Operating Systems

01 Introduction

The previous four lessons in this unit have been largely conceptual: what threads are, where they can live in the system, how the kernel schedules them, and what happens to a program when several of them run at once. This lesson is the practical counterpart. It introduces the POSIX threads API — commonly called pthreads — which is the standard interface for creating and managing threads in C on any UNIX-like system.

The pthreads standard was formalised in IEEE 1003.1c (1995) and is now part of POSIX.1. It is available on Linux (via glibc's Native POSIX Thread Library, examined in Lesson 4.3), on macOS, on the BSDs, and on Solaris. It is the API you will use for every laboratory and assignment in this course that involves threads. Learning it well is not just a matter of memorising a few function names — the pthread programming model has its own conventions for error handling, resource management, and thread lifecycle, and getting those conventions right is the difference between code that works and code that works reliably.

The lesson covers the five core functions (pthread_create, pthread_join, pthread_detach, pthread_exit, and the attribute machinery), the thread lifecycle from creation to completion, the standard compilation conventions, and the pitfalls — including a few that are specific to Linux and glibc. It closes with a complete working program that illustrates everything discussed, and prepares you for the thread-management work in Lab 3.

Prerequisites

This lesson builds on Lesson 4.3 (kernel threads — the model pthreads implements on Linux) and Lesson 4.4 (multicore processing — what pthreads enables). It also assumes the development toolchain from Lesson 2.5 is installed, since every example in this lesson compiles and runs in the course VM.

02 Learning objectives

By the end of this lesson you will be able to

  • Create threads with pthread_create.
  • Join threads with pthread_join.
  • Manage thread attributes.
  • Handle thread errors.
  • Write correct multithreaded code.

This lesson contributes to U4.4, U4.5, and to course learning outcomes CLO2, CLO6, and CLO8.

03 Required reading

  • OSTEP — Chapter 27, Interlude: Thread API. A concise introduction to the POSIX threads interface with example code.
  • Operating System Concepts — Chapter 4, Section 4.4, Thread Libraries, and the Pthreads examples throughout Chapter 4.
  • Butenhof, D. R. — Programming with POSIX Threads, Chapters 1–3. The classical reference for the pthreads programming model. Chapter 1 gives the philosophy, Chapter 2 covers thread creation and termination, and Chapter 3 covers attributes.
  • Linux man pages: man 7 pthreads, man 3 pthread_create, man 3 pthread_join, man 3 pthread_detach, man 3 pthread_exit, man 3 pthread_attr_init, man 3 pthread_attr_setdetachstate, man 3 pthread_attr_setstacksize, man 3 pthread_self, man 3 pthread_equal.

Reading strategy

Read OSTEP Chapter 27 first for the API surface, then Butenhof Chapter 2 for the design rationale and the conventions. Keep the man pages open while you work through the worked example — the descriptions of edge cases (particularly around the return values and the semantics of pthread_join on an already-joined thread) are more precise than the textbook treatments.

04 Core content

4.1 The pthread API in one page

The pthreads API is larger than it first appears, but the subset you will use in this course is small. Six functions cover almost every case.

The core pthread functions
Function Purpose
pthread_create Create a new thread that runs a specified function.
pthread_join Wait for a thread to terminate and collect its return value.
pthread_detach Mark a thread as detached so its resources are released automatically on exit.
pthread_exit Terminate the calling thread.
pthread_self Return the calling thread's identifier.
pthread_equal Compare two thread identifiers for equality.

All of them return an integer error code, discussed in section 4.7. All of them are declared in the header <pthread.h>. And all of them follow the conventions that POSIX established for the whole threading interface.

4.2 Creating a thread: pthread_create

The signature is:

#include <pthread.h>

int pthread_create(pthread_t *thread,
                   const pthread_attr_t *attr,
                   void *(*start_routine)(void *),
                   void *arg);

The four arguments have specific roles.

  • thread is a pointer to a pthread_t variable, which the function fills in with the new thread's identifier. This identifier is what you pass to pthread_join or pthread_detach later.
  • attr is a pointer to a pthread_attr_t structure describing thread attributes, or NULL to accept the defaults. The default attributes are almost always what you want; section 4.6 discusses when to override them.
  • start_routine is a function pointer to the thread's entry point. The function must have the exact signature void *(*)(void *) — it takes one void * argument and returns void *.
  • arg is the argument passed to start_routine. If you need to pass multiple values, wrap them in a struct and pass a pointer to it.

The function returns 0 on success or an error number on failure — not the convention of setting errno and returning −1 that the rest of POSIX uses. This inconsistency is important enough that section 4.7 is devoted to it.

A minimal creation call:

pthread_t t;
int err = pthread_create(&t, NULL, worker, NULL);
if (err != 0) {
    fprintf(stderr, "pthread_create: %s\n", strerror(err));
    exit(1);
}

4.3 The thread start routine

The start routine is an ordinary function, but its signature is fixed by the API. The most common form takes a pointer to a struct that holds the arguments:

struct args {
    int       id;
    double   *data;
    size_t    n;
    double    result;
};

void *worker(void *p) {
    struct args *a = p;
    double sum = 0.0;
    for (size_t i = 0; i < a->n; i++) sum += a->data[i];
    a->result = sum;
    return NULL;
}

The void * return value is the thread's exit value, which pthread_join can retrieve. A thread that has nothing to return can return NULL (or, equivalently, call pthread_exit(NULL)).

Returning a pointer to a local variable

A common mistake is to return the address of a local variable from the start routine:

void *worker(void *p) {
    int result = 42;
    return &result;   /* WRONG — the variable is gone on return */
}

The thread's stack is deallocated when the thread terminates, so the address is invalid by the time the caller sees it. To return an integer result, either return it cast to void * (if it fits), or store it in a location whose lifetime is controlled by the parent — for example, a field in the argument struct, or a value the parent passes in via pthread_join's second argument.

4.4 Waiting for threads: pthread_join

Threads are analogous to processes in that a thread may be joinable or detached. A joinable thread is one whose resources are not reclaimed by the system until another thread calls pthread_join on its identifier. A detached thread's resources are reclaimed automatically when it terminates, with no join required.

By default, pthread_create creates joinable threads. The signature of pthread_join is:

int pthread_join(pthread_t thread, void **retval);

The function blocks the calling thread until the target thread terminates. If retval is not NULL, the target thread's return value (the pointer it returned from its start routine, or the argument to pthread_exit) is stored at *retval. Once a thread has been joined, its resources are fully reclaimed and its identifier may be reused.

The essential rule for joinable threads: every thread that is created must eventually be joined or detached. A joinable thread that is never joined is a resource leak — its kernel task structure and stack remain allocated until the process terminates. This is the threading analogue of the zombie-process problem from Unit 3.

A joining pattern for multiple threads:

pthread_t t[N];
for (int i = 0; i < N; i++)
    pthread_create(&t[i], NULL, worker, &args[i]);

for (int i = 0; i < N; i++)
    pthread_join(t[i], NULL);

Note that the joins are in a separate loop from the creations. Creating all threads first and then joining them lets the threads run in parallel. If the create-then-join were done in one loop, each thread would be created and immediately joined, serialising the whole computation.

4.5 Detached threads: pthread_detach

Not every thread needs to be joined. A fire-and-forget thread — one whose result no one will ever collect — should be detached so that its resources are reclaimed on exit. There are two ways to create a detached thread.

The first is to call pthread_detach after creation:

pthread_t t;
pthread_create(&t, NULL, worker, NULL);
pthread_detach(t);

The second is to set the detach state in the attributes structure before calling pthread_create, which avoids the race between thread creation and the detach call:

pthread_attr_t attr;
pthread_attr_init(&attr);
pthread_attr_setdetachstate(&attr,
                            PTHREAD_CREATE_DETACHED);
pthread_create(&t, &attr, worker, NULL);
pthread_attr_destroy(&attr);

The race matters when the thread might terminate before pthread_detach is called on its identifier. If the thread has already terminated, its identifier may no longer refer to it, and the detach call may fail. Setting the attribute before creation eliminates the race entirely.

Joinable or detached: a design decision

A joinable thread is appropriate when the parent needs the thread's result or needs to know when it completed. A detached thread is appropriate when the work is fire-and-forget — for example, a background logging task, a telemetry heartbeat, or a request handler whose result is delivered elsewhere. Making this decision explicitly at creation time avoids resource leaks.

4.6 Thread attributes

The pthread_attr_t structure is an opaque type that describes the initial configuration of a new thread. Using it has three steps: initialise, set the values you care about, and destroy it after creating the thread.

pthread_attr_t attr;
pthread_attr_init(&attr);

/* ... pthread_attr_set... calls ... */

pthread_create(&t, &attr, worker, NULL);

pthread_attr_destroy(&attr);

The attributes you will most often set are:

Detach state
PTHREAD_CREATE_JOINABLE (the default) or PTHREAD_CREATE_DETACHED. Set with pthread_attr_setdetachstate.
Stack size
The size of the thread's user-space stack. The default on Linux is typically 8 MB, which is generous for ordinary threads but wasteful when creating thousands of them. Set with pthread_attr_setstacksize.
Stack address
The base address of the thread's stack. Rarely used; some applications pass a pre-allocated stack so they can control its placement, for example to keep thread stacks within a single NUMA node.
Scheduling policy and priority
SCHED_OTHER, SCHED_FIFO, or SCHED_RR with an associated priority. Real-time policies require privileges and are discussed in Unit 5.
Guard size
A region of protected memory placed after the thread's stack so that a stack overflow triggers a fault rather than silently corrupting adjacent memory. Default is one page.

4.7 Error handling and the -pthread flag

The pthreads functions return an integer error code and do not use errno. This is a deliberate design choice from POSIX 1003.1c: because errno is a per-thread variable on a modern system, but was not when pthreads was standardised, the interface opted to return errors directly and avoid any shared or thread-local state ambiguity.

The correct pattern is:

int err = pthread_create(&t, NULL, worker, NULL);
if (err != 0) {
    fprintf(stderr, "pthread_create failed: %s\n", strerror(err));
    return 1;
}

Never use perror with a pthreads error code: perror reads from errno, which the pthreads functions do not set. The result is a misleading message about a completely different error.

The other convention specific to pthreads is the compiler and linker flag -pthread. On modern glibc this flag both defines the preprocessor macro _REENTRANT (which enables thread-safe variants of some library functions) and links the program against the threading library. Without -pthread, the program may compile but produce incorrect behaviour under concurrency, or fail to link at all.

gcc -Wall -Wextra -g -O2 -pthread -o program program.c

On macOS and the BSDs, the same flag works, though modern versions of these systems often provide threading in the base C library. On Linux, the flag is mandatory.

4.8 The thread lifecycle

Bringing the functions together, a thread passes through a sequence of states that mirrors the process lifecycle from Unit 3 but is specific to the pthreads model.

The POSIX thread lifecycle A state diagram of a POSIX thread. Creation by pthread_create leads to Running. The thread may block and become runnable again, or terminate via return or pthread_exit. If joinable, the thread stays in the terminated state until pthread_join reaps it. If detached, the thread is reclaimed immediately on termination. pthread_cancel can also terminate a thread at a cancellation point. THE POSIX THREAD LIFECYCLE Running Blocked Terminated Resources reclaimed blocking call (read, wait, ...) event occurs return from start routine or pthread_exit() pthread_cancel at a cancellation point if detached automatic if joinable pthread_join() pthread_create() A joinable thread that is never joined is a resource leak — its kernel task and stack stay allocated until the process terminates. A detached thread's resources are released automatically on termination. Use attributes to avoid the create/detach race. pthread_cancel is a request, not a command: it takes effect only at a cancellation point.
Figure 4.5 — The POSIX thread lifecycle. A thread is created, runs, may block and wake, and eventually terminates. Its resources are reclaimed either by pthread_join (if joinable) or automatically on termination (if detached).

4.9 Thread-local storage

In Lesson 4.1 we saw that each thread has its own errno even though errno looks like a global variable. This is an example of thread-local storage (TLS): a variable that appears to each thread to be private, even though it is declared at file scope.

Thread-local storage is useful whenever a thread needs to maintain state across calls to library functions without passing that state explicitly. The POSIX interface has two forms.

The compiler-based form uses the _Thread_local storage-class specifier (C11; __thread in GCC and Clang). Each thread sees its own copy of the variable:

_Thread_local int counter = 0;

void bump(void) {
    counter++;      /* each thread increments its own copy */
}

The POSIX form uses pthread_key_create and pthread_setspecific to associate a value with a key that is unique to the calling thread. This is the older, more portable interface and is what the C library uses internally for errno:

pthread_key_t key;

void init(void) {
    pthread_key_create(&key, NULL);
}

void set(int value) {
    pthread_setspecific(key, (void *)(long)value);
}

int get(void) {
    return (int)(long)pthread_getspecific(key);
}

TLS is a small but important part of the pthreads programming model. It gives you a way to have per-thread state without passing it through every function call, and it is what makes the C library safe to use from multiple threads at once. We will return to it in Unit 6, when we discuss how synchronization primitives are themselves implemented using per-thread state.

4.10 Common pitfalls

The pthread API is small, but its programming model has a set of pitfalls that beginners reliably encounter. Most of them are consequences of the shared-address-space design from Lesson 4.1.

Forgetting -pthread

The program may compile and link but exhibit incorrect behaviour under concurrency, because some library functions are not replaced with their thread-safe variants. Always compile with -pthread.

Using errno for pthreads errors

Pthreads functions return their error codes directly. Using perror after a failed pthread_create reports a stale, unrelated error. Use strerror(err).

Returning a pointer to a local

The thread's stack is destroyed on exit. Returning a pointer into it produces undefined behaviour when the parent reads through it.

Passing the address of a loop variable

Creating threads in a loop and passing &i gives every thread the same address, whose value changes as the loop proceeds. Pass a per-thread struct instead.

Never joining a joinable thread

A joinable thread that is never joined leaks its kernel task and its stack. Either join it or create it detached.

Sharing data without synchronization

Threads share the heap and globals; unsynchronized access is a race condition. Even counter++ is not atomic. The subject of Unit 6.

Assuming thread scheduling order

The scheduler does not guarantee that threads run in the order they were created, nor that a thread runs immediately after pthread_create returns.

Using exit instead of pthread_exit

Calling exit from a thread terminates the entire process. To terminate only the calling thread, use pthread_exit or return from the start routine.

4.11 A note on pthread_cancel

POSIX provides pthread_cancel, which requests that another thread be cancelled. The mechanism is subtle: a cancel request is not a command that terminates the target immediately. The target thread will be terminated only when it next reaches a cancellation point — one of a defined set of functions, including read, write, sleep, wait, pthread_join, and pthread_cond_wait. Between cancellation points, the thread continues to run.

Cancellation also has two modes — deferred (the default) and asynchronous — and the state can be disabled entirely. Because correct cancellation requires the target thread to handle a signal-like event and clean up after itself (using pthread_cleanup_push and pthread_cleanup_pop), the mechanism is more complex than it first appears.

In this course, you will not need to use pthread_cancel. When a thread must be asked to stop, the conventional pattern is a shared flag that the thread checks periodically — a form of cooperative cancellation that is simpler and more predictable than the POSIX mechanism. We will use this pattern in the threading laboratory.

05 Worked example

To make the API concrete, walk through a complete program that uses every function discussed. It creates a fixed number of worker threads, each of which computes a partial result; the main thread joins them all and combines the results.

Parallel sum with pthreads

The task is to sum an array of 400 million integers. The array is partitioned across four threads; each thread sums its own slice and writes its partial sum to its own slot in a result array; the main thread joins the four threads and combines the results. The program also prints a per-thread diagnostic that includes each thread's identifier.

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

#define N        400000000L
#define WORKERS  4

static long  data[N];
static long  partial[WORKERS];

struct args {
    int    id;         /* thread index */
    size_t start;
    size_t end;
};

static void *sum_worker(void *p) {
    struct args *a = p;
    long s = 0;
    for (size_t i = a->start; i < a->end; i++) s += data[i];
    partial[a->id] = s;
    printf("thread %d: pid=%d, sum=%ld\n",
           a->id, (int)getpid(), s);
    return NULL;
}

static double now(void) {
    struct timespec ts;
    clock_gettime(CLOCK_MONOTONIC, &ts);
    return ts.tv_sec + ts.tv_nsec / 1e9;
}

int main(void) {
    for (long i = 0; i < N; i++) data[i] = 1;

    /* Argument structs must persist until each thread is joined,
       so they live in the main thread's stack. */
    struct args args[WORKERS];
    pthread_t   tids[WORKERS];

    size_t per = N / WORKERS;

    double t0 = now();

    for (int w = 0; w < WORKERS; w++) {
        args[w].id    = w;
        args[w].start = w * per;
        args[w].end   = (w == WORKERS - 1) ? N : (w + 1) * per;

        int err = pthread_create(&tids[w], NULL, sum_worker, &args[w]);
        if (err != 0) {
            fprintf(stderr, "pthread_create: %s\n", strerror(err));
            exit(1);
        }
    }

    for (int w = 0; w < WORKERS; w++) {
        int err = pthread_join(tids[w], NULL);
        if (err != 0) {
            fprintf(stderr, "pthread_join: %s\n", strerror(err));
            exit(1);
        }
    }

    long total = 0;
    for (int w = 0; w < WORKERS; w++) total += partial[w];

    double t1 = now();
    printf("total = %ld\n", total);
    printf("elapsed = %.3f s\n", t1 - t0);
    return 0;
}

Build and run:

gcc -Wall -Wextra -g -O2 -pthread -o psum psum.c
./psum

Output on a four-core VM:

thread 0: pid=1234, sum=100000000
thread 2: pid=1234, sum=100000000
thread 1: pid=1234, sum=100000000
thread 3: pid=1234, sum=100000000
total = 400000000
elapsed = 0.28 s

Three things are worth noting in the output.

  • The order of the "thread" lines varies between runs. The scheduler does not guarantee that the threads run in the order they were created. Running the program several times will show different interleavings.
  • Every thread reports the same PID. On Linux, all threads of a process share the same PID — the thread group ID from Lesson 4.3 — even though each has its own kernel task ID. To print the thread's own TID you would need to call gettid() (Linux-specific) or read it from /proc/self/task/.
  • The elapsed time is a fraction of the single-threaded time. On a four-core VM, four threads processing four times as much data in roughly the same time as one thread processing the whole array is what the multicore scaling from Lesson 4.4 looks like in practice.

Adding error handling and attributes

The example above uses the default attributes and simple error handling. A more complete version would validate every return value, use an attribute structure to configure a smaller stack for the workers, and clean up the attributes after creation:

pthread_attr_t attr;
int err = pthread_attr_init(&attr);
if (err != 0) { /* handle */ }

/* 1 MB stack instead of the default 8 MB */
err = pthread_attr_setstacksize(&attr, 1 << 20);
if (err != 0) { /* handle */ }

/* Detached: the workers have nothing to return */
err = pthread_attr_setdetachstate(&attr,
                                  PTHREAD_CREATE_DETACHED);
if (err != 0) { /* handle */ }

for (int w = 0; w < WORKERS; w++) {
    err = pthread_create(&tids[w], &attr, sum_worker, &args[w]);
    if (err != 0) { /* handle */ }
}

pthread_attr_destroy(&attr);

/* Nothing to join — the workers are detached */

Note that the argument structs would need to outlive the workers in this version, because the parent no longer waits for them to complete. A common design is to make them global or heap-allocated. This is one of the consequences of moving to detached threads: the parent must not use stack-allocated memory that a detached thread might still be reading after the parent returns from its function.

Measuring the thread switch cost

As a final step, replace the sum loop with an empty loop that calls sched_yield one million times in each worker, and time the run. The result is a rough upper bound on the kernel-thread switch cost, which we compared against the user-thread switch from Lesson 4.2:

Kernel-thread switch (measured on a modern x86-64 VM):
    Cost per switch:      ~170 ns

User-thread switch (measured in Lesson 4.2):
    Cost per switch:      ~70 ns

The kernel-thread switch is roughly twice as expensive as the user-thread switch on this hardware. Both numbers are much lower than the raw process-switch measurement from Lesson 3.5 — the simplification is that the address space does not change, so the TLB and cache effects are much smaller.

06 Interactive activity

Activity · build, run, and stress the pthread API

Work through the following in your course VM. The goal is to use every function discussed in the lesson and to observe the behaviours — including the pitfalls — that matter most in practice.

  1. Build the parallel sum. Copy the psum.c program from the worked example into your lab directory. Compile with the course flags and run it several times.
    gcc -Wall -Wextra -g -O2 -pthread -o psum psum.c
    ./psum
    ./psum
    ./psum
    Does the ordering of the "thread" lines change between runs? What does this tell you about thread scheduling?
  2. Change the number of workers. Modify the program to accept the number of workers on the command line (use atoi on argv[1]). Run it with 1, 2, 4, and 8 workers. Record the elapsed times. Compute the speedup relative to the one-worker case at each thread count.
  3. Read the man page for pthread_join.
    man 3 pthread_join
    Read the RETURN VALUE and ERRORS sections carefully. What does the man page say about joining a thread that has already been joined? What error code is returned?
  4. Deliberately join a thread twice. Modify the program to call pthread_join twice on the first thread. Compile and run. What does the second call return? Was the error reported clearly?
  5. Add a detached thread. Write a small program that creates a thread, detaches it using pthread_detach, and then returns from main. Note that if you return from main without calling pthread_exit, the entire process terminates and the detached thread is killed immediately, even if it has not finished its work. Verify this by having the detached thread print something after a short sleep.
  6. Use an attribute structure. Modify the parallel sum to use an attribute structure that sets a 1 MB stack size and the detached state.
    pthread_attr_t attr;
    pthread_attr_init(&attr);
    pthread_attr_setstacksize(&attr, 1 << 20);
    pthread_attr_setdetachstate(&attr, PTHREAD_CREATE_DETACHED);
    /* ... create threads with &attr ... */
    pthread_attr_destroy(&attr);
    Note that the workers now cannot be joined. Ensure that the argument structs outlive the threads by making them global or heap-allocated. Rebuild and run.
  7. Introduce a race. Modify the program so that all workers increment a single shared counter (instead of writing to separate slots) a million times each. Compile without any locking and run several times. Compare the result to the expected value. Note that the discrepancy varies between runs. This is a preview of Unit 6, and it demonstrates why the "sharing" advantage of threads from Lesson 4.1 has a cost.
  8. Inspect the threads of a running program. Start the parallel sum with a large enough workload that it takes several seconds. While it is running, open another terminal and inspect it:
    PID=$(pgrep psum)
    ps -L -p $PID -o pid,tid,stat,pcpu,comm
    ls /proc/$PID/task/
    You will see the main thread plus the four workers, each with its own tid and state.

Reflect: which of the API functions was easiest to use? Which required the most care? Which of the pitfalls from section 4.10 did you encounter yourself, and how did you resolve it? If you were designing a new threading API today, what would you change about the pthreads interface?

07 Knowledge check

Check your understanding

Question 1

What is the exact signature required of a thread's start routine, and why does the API impose this signature?

Show answer
The start routine must have the signature void *(*)(void *) — a function that takes one void * argument and returns a void *. The API imposes this because it needs a uniform way to pass a single argument in and receive a single value out, without knowing anything about the types involved. The void * argument lets the caller pass a pointer to anything — typically a struct holding the thread's parameters — and the void * return value lets the thread report any result, which pthread_join can then retrieve.

Question 2

What is the difference between a joinable thread and a detached thread, and when would you use each?

Show answer
A joinable thread is one whose resources are not reclaimed until another thread calls pthread_join on its identifier. It is appropriate when the parent needs the thread's result, or needs to know when the thread has finished — for example, a worker in a parallel computation whose partial result is combined with others. A detached thread's resources are reclaimed automatically when it terminates. It is appropriate when the work is fire-and-forget and no one needs the result or the completion signal — for example, a background logging task, or a request handler whose result is delivered through another channel. Every thread must be either joined or detached; a joinable thread that is never joined leaks its resources.

Question 3

Why must a pthreads program be compiled with -pthread, and what could go wrong if it is not?

Show answer
The -pthread flag does two things. First, it defines the preprocessor macro _REENTRANT (on some systems) or its equivalent, which enables the thread-safe variants of some C library functions and inline macros. Second, it links the program against the threading library, which provides the implementations of pthread_create and the other pthreads functions. Without the flag, the program may fail to link with unresolved symbols, or (worse) it may compile and link but behave incorrectly under concurrency because some library functions are not thread-safe in the default configuration. Always compile pthreads programs with -pthread, and consider adding it to the Makefile so that no one can forget it.

Question 4

A program creates a thread in a loop, passing &i as the argument where i is the loop variable. The thread start routine casts the argument to int and prints it. Almost every thread prints the final value of i rather than the value it had when the thread was created. Explain what went wrong and how to fix it.

Show answer
The loop passes the address of the single loop variable i to every thread. When the thread runs, it dereferences the pointer and reads whatever value i currently holds — which, by the time any thread actually executes, is likely the final value after the loop terminated (or an arbitrary intermediate value, depending on timing). The fix is to pass a per-thread value, not the address of the loop variable. Three common approaches: pass the value directly as a void * (casting the integer to a pointer, which works if it fits), allocate a per-thread argument on the heap and pass its address, or use an array of argument structs indexed by i. The corresponding rule is that any memory a thread reads through its argument must outlive the thread.

Question 5

Why does pthread_cancel not terminate the target thread immediately, and what is the practical consequence for programs that use it?

Show answer
pthread_cancel is a request, not a command. The target thread will be terminated only when it next reaches a cancellation point — one of a defined set of functions, such as read, write, sleep, wait, pthread_join, or pthread_cond_wait. Between cancellation points the thread continues running normally, and if it never reaches one, it is never cancelled. This design gives the target thread a chance to clean up after itself: it can register cleanup handlers with pthread_cleanup_push that run before the thread is terminated, so locks are released, files are closed, and memory is freed. The practical consequence is that a program relying on cancellation must identify the cancellation points in its threads, and must ensure that no critical work is left in an inconsistent state. Many programs avoid pthread_cancel entirely and use a cooperative flag instead, which is simpler to reason about and does not depend on the placement of cancellation points.

08 Unit discussion

Discussion prompt

The pthread API is a C interface: it uses void * pointers, requires manual resource management, and relies on the programmer to remember the conventions for error handling, detach state, and joins. Higher-level languages offer very different interfaces — Java's Thread class, Python's threading module, Rust's std::thread, Go's goroutines — each with its own balance of safety, expressiveness, and performance.

Imagine you are designing a new threading interface for systems programming in 2025. What design choices would you make differently from pthreads, and why? Consider: error handling, resource management, thread lifecycle, typing of thread arguments and results, and the interaction with modern language features such as ownership and async/await. Argue for the choices you would make.

Post your response to the Unit 4 discussion forum and reply to at least two classmates. This closes Unit 4. Supports CLO2, CLO6, and CLO8.

09 Practice exercise

This ungraded exercise consolidates the lesson and prepares you for Lab 3 and the thread portion of Assignment 1. Aim to spend 60–90 minutes on it.

  1. Write a pthreads program from scratch. Write a program that creates four threads, each of which sleeps for a different number of seconds (one, two, three, and four respectively) and then prints its own thread identifier along with the time it woke up. The main thread joins all four and prints a final message. Note that the output ordering is determined by the sleep times, not by the creation order.
  2. Return values from threads. Modify the program so each thread returns a value indicating the number of seconds it slept. In the main thread, retrieve each return value with pthread_join and print it. Note the technique used to pass an integer through a void *.
  3. Compare joinable and detached. Rewrite the program so that the four sleeping threads are detached rather than joinable. Run it. What happens if the main thread returns from main before the longest-sleeping thread has woken up? Then modify the main thread to sleep for five seconds before returning. Does the detached thread now finish? Explain what you observe.
  4. Configure attributes. Modify the program to use an attribute structure that sets a 256 KB stack size. Create a thousand threads with that attribute and have them all sleep for a minute. While the program is running, check the process's virtual memory usage:
    grep VmSize /proc/<PID>/status
    Roughly how much virtual address space do a thousand 256 KB stacks consume? Compare to what the same program would consume with the default 8 MB stacks.
  5. Write a shell wrapper. Write a small shell script that compiles and runs your pthread program, printing the number of CPUs first and then the elapsed time:
    #!/bin/bash
    echo "cores: $(nproc)"
    gcc -Wall -Wextra -g -O2 -pthread -o prog prog.c
    for n in 1 2 4; do
        ./prog $n
    done
    This script will be useful for the measurements in Lab 3.
  6. Read a man page carefully. Read man 7 pthreads from beginning to end. List every function it mentions that you have not used, and for each one write a one-sentence description of what it does. This gives you a map of the API's full surface, so you know what exists when you need it later.
  7. Reflect. In your own words, describe the single most important convention in the pthread programming model — the one that, if you forget it, will cause the most trouble — and explain why it matters.

10 Summary

  • POSIX Threads (pthreads) is the standard C interface for threading on UNIX-like systems, including Linux, macOS, and the BSDs.
  • The core functions are pthread_create (create a thread), pthread_join (wait for a joinable thread), pthread_detach (mark a thread as detached), and pthread_exit (terminate the calling thread).
  • A thread's start routine has the fixed signature void *(*)(void *). Arguments and return values pass through void * pointers, usually to a struct that holds multiple values.
  • Every thread is either joinable (its resources are reclaimed by pthread_join) or detached (its resources are reclaimed automatically on exit). A joinable thread that is never joined leaks its resources.
  • The pthread_attr_t structure configures a thread's initial state: detach state, stack size, stack address, scheduling policy, and guard size. Set attributes before calling pthread_create to avoid races.
  • Pthreads functions return error codes directly; they do not set errno. Use strerror(err), not perror.
  • Always compile pthread programs with -pthread, which enables thread-safe library variants and links the threading implementation.
  • Thread-local storage (via _Thread_local or the pthread_key_create API) gives each thread a private copy of a variable that appears at file scope. It is what makes the C library safe under concurrency.
  • Common pitfalls: forgetting -pthread, using errno for pthreads errors, returning pointers to thread-local variables, passing the address of a loop variable, never joining joinable threads, sharing data without synchronization, assuming scheduling order, and calling exit instead of pthread_exit.

Unit 4 complete

This closes the thread unit. You now have both the theory — the thread abstraction, user and kernel models, multicore scaling — and the practice: the pthread API that turns the theory into working code. Unit 5 turns to scheduling, and Unit 6 to the synchronization mechanisms that make multithreaded programs correct.

11 References

  • Arpaci-Dusseau, R. H., & Arpaci-Dusseau, A. C. Operating Systems: Three Easy Pieces, Chapter 27. Available online at ostep.org.
  • Silberschatz, A., Galvin, P. B., & Gagne, G. Operating System Concepts, 10th ed., Chapter 4, Section 4.4.
  • Butenhof, D. R. Programming with POSIX Threads, Chapters 1–3. Addison-Wesley, 1997.
  • Drepper, U., & Molnar, I. The Native POSIX Thread Library for Linux. Red Hat, 2003.
  • Linux man-pages: man 7 pthreads, man 3 pthread_create, man 3 pthread_join, man 3 pthread_detach, man 3 pthread_exit, man 3 pthread_attr_init, man 3 pthread_key_create, man 3 pthread_self, man 3 pthread_equal.
  • POSIX.1-2017 / IEEE Std 1003.1, Standard for Information Technology — Portable Operating System Interface (POSIX), Section on Threads.
👤