COMP 3150Operating Systems

01 Introduction

Lesson 4.1 established what a thread is and why splitting execution from the process is useful. It left open a design question: where should the thread abstraction be implemented? Two answers exist, and they lead to very different systems.

The first answer is user-level threads: the thread abstraction lives entirely in a library running in the process's own address space. The library provides operations to create, schedule, and switch between threads, and it does all of this without ever asking the kernel for help. From the kernel's point of view, a process with a hundred user-level threads is indistinguishable from a process with one — the kernel sees a single stream of control, and it schedules that stream like any other.

This was the original threading model, developed at Carnegie Mellon and elsewhere in the 1980s, and it has several compelling properties. Switching between user threads is extremely fast because it does not involve a trap into the kernel. The library can implement whatever scheduling policy the application needs. The system requires no kernel support for threads at all, so user threads work on any UNIX-like system.

The model also has one fundamental limitation that eventually pushed modern systems toward kernel-level threads. This lesson examines how user threads work, why they are fast, what they cost, and what the workarounds are — and it prepares the ground for Lesson 4.3, which presents the kernel-level alternative.

Prerequisites

This lesson builds on Lesson 4.1 (the thread concept, the shared/per-thread split), Lesson 3.2 (the process state model), and Lesson 3.5 (context switching). The blocking problem in particular depends on understanding the difference between the Blocked and Ready states.

02 Learning objectives

By the end of this lesson you will be able to

  • Define user-level threads.
  • Describe library-based scheduling.
  • Explain the blocking problem.
  • Analyse performance trade-offs.
  • Evaluate user-thread use cases.

This lesson contributes to U4.2 and to course learning outcomes CLO2 and CLO8.

03 Required reading

  • Operating System Concepts — Chapter 4, Section 4.3, Multithreading Models. The classical presentation of user threads, kernel threads, and the models that combine them.
  • OSTEP — Chapter 26, the discussion of user-level threading and the historical development of threading libraries. OSTEP's framing of the blocking problem is clear and concise.
  • Draves, R., Bershad, B., Rashid, R., & Dean, R. — Using Continuations to Implement Thread Management and Communication in Operating Systems. SOSP, 1991. The classical paper on user-level thread implementation techniques, including the scheduler-activation model.
  • Linux man pages: man 3 makecontext, man 3 swapcontext, man 3 setcontext. The POSIX ucontext family, which is the modern interface for user-level context switching.
  • GNU Pth documentation and the historical documentation for the original Mach C-threads library, both freely available online. These show what a real user-level threading library looks like.

Reading strategy

Read Silberschatz Section 4.3 first for the taxonomy of threading models, then OSTEP for the framing of the blocking problem. The Draves paper is dense — the section on continuations is worth understanding, but do not worry if the details of the Mach implementation are unclear on a first reading.

04 Core content

4.1 What a user-level thread is

A user-level thread is a thread that is created, scheduled, and managed entirely by a library running in the process's own address space. The kernel is not involved. It does not allocate any resources for the thread, does not know the thread exists, and does not schedule it.

From the kernel's point of view, a process that uses a user-level thread library looks exactly like a single-threaded process: one program counter, one stack, one kernel-side task. The thread library has created the illusion of concurrency entirely in user space, by rapidly switching among its own threads within the single kernel-visible stream of control.

This is the defining feature of the model. It is also the source of its strengths and its central limitation.

4.2 The library and the thread control block

A user-level threading library maintains its own data structure for each thread, called a thread control block (TCB). The TCB is the user-space analogue of the kernel's process control block from Lesson 3.3, but it is much smaller. It typically holds only what the library needs to switch between threads:

  • A saved context. The register values — the program counter, the stack pointer, general-purpose registers, and status flags — from the last time the thread was suspended. This is what allows the thread to resume where it left off.
  • A thread-local stack. Each thread must have its own stack, allocated by the library from the process's heap or a separate memory region.
  • A state field. The library maintains its own notion of which threads are runnable, which are blocked on what, and which have terminated.
  • A thread ID. A library-visible identifier, independent of any kernel-level identifier.
  • Scheduling metadata. Priority, runnable time, and whatever else the library's scheduling policy requires.

The library keeps its TCBs in a data structure of its own choosing: a list, a queue, a priority heap, or a red-black tree. This choice belongs to the library and is invisible to the kernel.

4.3 How a user-thread switch works

A user-thread switch is a pure user-space operation: the library saves the outgoing thread's registers into its TCB, restores the incoming thread's registers from its own TCB, and transfers control to the incoming thread's saved program counter. No privileged instruction is executed. No trap into the kernel occurs. No mode switch happens.

On Linux, the modern mechanism for performing this switch is the ucontext family: getcontext, setcontext, makecontext, and swapcontext. These are provided by the C library and implemented in a small amount of assembly. A swapcontext call performs the entire switch and is typically a few dozen instructions.

#include <ucontext.h>

/* A thread control block for a minimal user-thread library */
typedef struct uthread {
    ucontext_t       ctx;       /* saved register state */
    char            *stack;     /* thread-private stack */
    size_t           stack_size;
    int              state;     /* RUNNABLE / BLOCKED / TERMINATED */
    struct uthread  *next;      /* for the library's run queue */
} uthread_t;

/* The user-thread switch: pure user space, no kernel involved. */
void uthread_switch(uthread_t *from, uthread_t *to) {
    from->state = RUNNABLE;
    to->state   = RUNNING;
    swapcontext(&from->ctx, &to->ctx);
}

The absence of any kernel involvement is what makes the switch so cheap. A kernel-mediated thread switch must execute a trap instruction, save state on the kernel stack, run the scheduler, restore state, and execute a return-from-trap — a sequence that costs hundreds to thousands of cycles. A user-space switch is a handful of mov instructions and a ret. In round numbers, a user-thread switch is often 10–50× cheaper than a kernel-thread switch on the same hardware.

User-level threads and the blocking problem Two panels. On the left, the normal case: three user threads run in rotation under the library scheduler within a single kernel-visible process. On the right, the blocking case: one user thread issues a blocking system call, the kernel blocks the entire process, and the other two user threads cannot run. USER THREADS AND THE BLOCKING PROBLEM NORMAL CASE library rotates among threads Thread 1 runnable running now Thread 2 runnable waiting its turn Thread 3 runnable waiting its turn library schedules these three threads User-level thread library swapcontext between threads · pure user space Single kernel-visible task the kernel sees one thread of control CPU · one core busy Switching is ~10–50× faster than a kernel-mediated thread switch. Library controls its own scheduling policy. BLOCKING CASE one thread blocks the whole process Thread 1 issued read() blocked in kernel Thread 2 runnable, but cannot be scheduled Thread 3 runnable, but cannot be scheduled library cannot run — it is inside the blocked process User-level thread library not executing — the process is suspended Kernel task · BLOCKED waiting for disk I/O to complete CPU idle (for this process) All user threads of the process are stalled. The kernel cannot schedule the other threads because it does not know they exist.
Figure 4.2 — The blocking problem. In the normal case (left), the library rotates among its threads and the kernel sees a single continuously running task. In the blocking case (right), one thread issues a blocking system call and the entire process is suspended — the other user threads cannot run because the library is not executing.

4.4 The library schedules, not the kernel

Because the kernel does not know about user threads, it cannot schedule them. The library must. This is a significant responsibility, and it gives the library both an opportunity and a burden.

The opportunity: the library can implement whatever scheduling policy the application needs. A database might use a priority policy tuned for its queries; a parallel-scientific workload might use a work-stealing policy optimised for cache locality; a graph algorithm might use a policy that prioritises breadth-first frontier expansion. A general-purpose kernel could never know enough about the application to make these choices well, but a library can.

The burden: the library must decide when to switch. In a cooperative model — which is what most user-level thread libraries use — the running thread must explicitly yield control by calling a library function such as uthread_yield or an equivalent. There is no timer in user space that can preempt the thread at an arbitrary point. If the running thread never yields (because it is CPU-bound and does not know it should cooperate), no other thread in the process will ever run.

The consequence is that most user-level thread libraries require the programmer to insert yield points at appropriate places in the code. This is a form of cooperative multitasking at the thread level, and it is why early user-level thread systems were sometimes called "green threads" — they depended on the application's cooperation for any kind of parallelism.

4.5 The blocking problem

This is the fundamental limitation of the user-thread model. Suppose thread 1 calls read on a slow device. From the kernel's point of view, the calling process is executing a system call that cannot complete immediately, so the kernel blocks the process. The process moves from Running to Blocked in the state model from Lesson 3.2.

But now consider what this means for the other user threads of the same process. Thread 2 and thread 3 are runnable — from the library's point of view, they are sitting on the library's run queue, ready to execute. But the library is not running. It cannot schedule them, because the library itself is user-space code inside the blocked process. The kernel has suspended the entire process, and with it everything inside it, including the scheduler that might have switched to thread 2.

The consequence is stark: a blocking system call in any one user thread blocks the entire process. Not just the thread that made the call — the whole thing, including all the other threads. From the kernel's perspective this is correct behaviour, because the kernel only knows about the single process. But from the application's perspective it is a serious failure: a design that was supposed to give concurrent execution stops making progress as soon as any thread touches a slow device.

This is why modern systems use kernel threads

The blocking problem is the single most important reason why kernel-level threads became the dominant model. A kernel thread that blocks is simply moved off the run queue; the other threads continue. The kernel can schedule each thread independently because it knows they exist. Lesson 4.3 examines how this works.

4.6 Workarounds for the blocking problem

Because the fundamental design has a serious limitation, researchers and library designers developed a set of techniques to work around it. All of them aim to prevent the process from ever making a blocking system call — because if the process never blocks, the library is always running and can always reschedule.

Non-blocking I/O
Replace every blocking operation with a non-blocking variant. On Linux, this means opening files and sockets with O_NONBLOCK, using select or epoll to discover which descriptors are ready, and never issuing a call that would block. The library, when it detects that an operation would block, marks the calling thread as blocked and switches to another. When the kernel later reports the descriptor as ready, the library can wake the waiting thread. This works, but it requires that every blocking operation in the program be replaced with a non-blocking equivalent — including inside libraries the program links against, which the programmer may not control.
Wrapper libraries (jacketing)
Interpose a wrapper around every blocking call. Before calling the underlying system call, the wrapper checks whether it is safe to proceed. If not, it registers the calling thread with the thread library as blocked and yields to another thread. When the operation completes, the library unblocks the original thread. This is called "jacketing" because the wrapper is a small jacket around the real call. It is transparent to the caller, but it is not always possible to interpose on every call — a statically-linked library that internally blocks may bypass the wrapper entirely.
Scheduler activations
A more sophisticated technique, described in the Draves paper. The kernel cooperates with the user-level thread library by notifying it when the underlying kernel thread is about to block. The library can then switch to another user thread before the kernel suspends the process. This gives the best of both worlds — user-level scheduling with kernel-mediated notifications — at the cost of a more complex kernel interface. Scheduler activations were implemented in research systems but never widely adopted in production.
Hybrid models (many-to-many)
Use multiple kernel threads, each running a set of user-level threads. The library multiplexes its user threads onto the available kernel threads. If one user thread blocks in the kernel, only the kernel thread it was running on is blocked; the others continue running their own user threads. This is the model used by Solaris's original threading library and by many research systems. It combines the efficiency of user-level switching with partial protection against blocking, at the cost of significant implementation complexity.

Each workaround has its own cost. Non-blocking I/O requires rewriting every operation. Jacketing requires interposing on system-call boundaries. Scheduler activations require kernel cooperation. Hybrid models require sophisticated libraries. None of them entirely solves the problem: even with all of them applied, there are cases where the process will still block and take all its user threads down with it.

4.7 The multicore limitation

A second fundamental limitation of pure user threads is that they cannot run on multiple cores simultaneously. Because the kernel sees only one schedulable entity per process, and because the library's switch is a user-space operation that runs on whichever core the process happens to be on, at most one user thread of a process can execute at any instant — even if the machine has many cores available.

This was not a serious concern when user threads were invented (in the 1980s, when single-core machines were the norm). It is a fatal flaw on modern hardware. A program that wants to exploit four cores cannot do so with pure user threads; it must either create separate processes or use kernel-level threads that the kernel can schedule across cores independently.

Lesson 4.4 examines the multicore implications in detail, and Lesson 4.3 explains how the kernel-thread model addresses them. For now, the important point is that the multicore era made the user-thread model's limitations more severe than they had been in the past.

4.8 Advantages of user threads

Despite the two fundamental limitations, user threads have real advantages. Some of these keep the model alive in specialised contexts; all of them motivated the design of hybrid systems that capture the best of both worlds.

Extremely fast switches

A user-thread switch is a handful of mov instructions and a ret, because no kernel trap is involved. It is typically 10–50× faster than a kernel-mediated thread switch. For a workload that switches frequently between many lightweight tasks, the difference is measurable.

Application-specific scheduling

The library can implement whatever policy the application knows is right — priority, work-stealing, cache-aware ordering — rather than being constrained to the kernel's general-purpose scheduler. This is the model used by programming-language runtimes that expose their own concurrency primitives.

Portability

A user-thread library runs on any UNIX-like system because it uses only ordinary user-space facilities (the ucontext family, or hand-written assembly for the switch). It does not depend on kernel support for threads, which was inconsistent in the 1980s and 1990s.

No kernel changes required

Adding threads to a system that does not have them requires no kernel modification. This made user threads attractive in embedded and specialised contexts where kernel changes are impractical.

Small per-thread footprint

Because the kernel does not allocate resources for a user thread, the library can make each thread very lightweight — often just a small stack and a TCB. This allows hundreds of thousands of user threads in a single process, which is what makes modern runtimes like Go's goroutines feasible.

4.9 Historical development and modern relevance

The user-thread model dominated academic threading research in the late 1980s and early 1990s. Its canonical implementations include:

  • Mach C-threads. The user-level thread library for the Mach microkernel. Its design and its interactions with kernel threads were examined in the Draves paper and influenced nearly every subsequent library.
  • Sun's green threads. The original Java virtual machine ran threads in user space to avoid the portability problems of kernel threads across platforms. It was replaced in Java 1.2 by a native-threads implementation, for the same reasons discussed in this lesson.
  • GNU Pth. A portable user-level thread library for UNIX-like systems, still available today.
  • ucontext-based libraries. Many small libraries and research prototypes have been built on the POSIX ucontext family over the years.

Modern systems rarely use pure user threads for general-purpose concurrency, because the blocking and multicore problems are too severe. But the model has not disappeared — it has been reincarnated in a different form. Modern language runtimes such as Go (goroutines), Erlang (processes), and Rust's async ecosystem implement their own lightweight tasks in user space and multiplex them onto a smaller number of kernel threads using exactly the techniques described in this lesson. The difference from the 1980s user-thread model is that the runtime controls the whole system — including every blocking call the program can make — so the workarounds are built in rather than bolted on.

The user-thread idea survives in modern runtimes

When you write a Go program and spawn a goroutine, the Go runtime is doing exactly what this lesson describes: creating a user-level task, scheduling it within a smaller number of kernel threads, and switching between tasks with a pure user-space context switch. The runtime also installs its own non-blocking I/O wrappers on every system call, so the blocking problem is solved by construction. This is the historical arc — the user-thread idea did not go away, it moved from the operating system into the runtime.

4.10 When user threads are appropriate

Even with the blocking and multicore limitations, there are three contexts in which a pure or hybrid user-thread design remains appropriate.

  1. When the workload is CPU-bound and single-threaded at the process level. If the tasks never block and the process will only ever run on one core, the multicore and blocking limitations do not apply. A user-thread library gives the fastest possible switches for the workload.
  2. Inside a language runtime that controls all I/O. Go, Erlang, and Node.js are all examples. The runtime has replaced the blocking calls with its own non-blocking wrappers, so the process never blocks in a way the library cannot handle. In this context, user threads become a first-class scheduling abstraction.
  3. For resource-constrained environments. When the per-thread kernel resources of the kernel-thread model (kernel stacks, task_structs) are too expensive, user threads provide a much lighter alternative. This is why some embedded systems and microcontrollers use user-thread libraries.

In all three contexts, the user-thread model is used with full awareness of its limitations, in a situation where those limitations do not matter or have been circumvented. Outside those contexts, the kernel-thread model is the correct default, and it is the subject of the next lesson.

05 Worked example

To make the user-thread model concrete, look at a small user-thread library and observe both its speed and its blocking behaviour.

A minimal user-thread library, in C

The following program implements a very small user-thread library on top of the POSIX ucontext family. It provides three operations: create a thread, yield to the next runnable thread, and exit the current thread. The scheduler is a simple round-robin over a fixed-size array of TCBs.

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

#define MAX_THREADS 8
#define STACK_SIZE  65536

typedef enum { RUNNABLE, RUNNING, DONE } state_t;

typedef struct {
    ucontext_t  ctx;
    char        stack[STACK_SIZE];
    state_t     state;
    const char *name;
} uthread_t;

static uthread_t threads[MAX_THREADS];
static int       n_threads   = 0;
static int       current     = -1;
static ucontext_t main_ctx;   /* to return to when all threads finish */

/* Forward declaration */
static void uthread_switch(void);

/* Round-robin: pick the next RUNNABLE thread */
static void uthread_schedule(void) {
    for (int i = 1; i <= n_threads; i++) {
        int j = (current + i) % n_threads;
        if (threads[j].state == RUNNABLE) {
            int prev = current;
            current = j;
            threads[j].state = RUNNING;
            if (prev >= 0 && threads[prev].state == RUNNING)
                threads[prev].state = RUNNABLE;
            swapcontext(&threads[prev < 0 ? 0 : prev].ctx, &threads[j].ctx);
            return;
        }
    }
    /* No runnable thread: return to main */
    swapcontext(&threads[current].ctx, &main_ctx);
}

/* Yield: give up the CPU to the next runnable thread */
void uthread_yield(void) {
    uthread_switch();
}

static void uthread_switch(void) {
    int prev = current;
    threads[prev].state = RUNNABLE;
    uthread_schedule();
}

/* Thread entry wrapper */
static void uthread_exit(void) {
    threads[current].state = DONE;
    uthread_schedule();
}

/* Create a new thread */
int uthread_create(const char *name, void (*fn)(void)) {
    if (n_threads >= MAX_THREADS) return -1;
    int id = n_threads++;

    uthread_t *t = &threads[id];
    t->name  = name;
    t->state = RUNNABLE;

    getcontext(&t->ctx);
    t->ctx.uc_stack.ss_sp   = t->stack;
    t->ctx.uc_stack.ss_size = STACK_SIZE;
    t->ctx.uc_link         = &main_ctx;
    makecontext(&t->ctx, fn, 0);
    return id;
}

/* Run all threads to completion */
void uthread_run(void) {
    current = 0;
    threads[0].state = RUNNING;
    swapcontext(&main_ctx, &threads[0].ctx);
}

/* ---- Demo ---- */
static void worker_a(void) {
    for (int i = 0; i < 5; i++) {
        printf("A: %d\n", i);
        uthread_yield();
    }
    uthread_exit();
}

static void worker_b(void) {
    for (int i = 0; i < 5; i++) {
        printf("B: %d\n", i);
        uthread_yield();
    }
    uthread_exit();
}

static void worker_c(void) {
    for (int i = 0; i < 5; i++) {
        printf("C: %d\n", i);
        uthread_yield();
    }
    uthread_exit();
}

int main(void) {
    uthread_create("A", worker_a);
    uthread_create("B", worker_b);
    uthread_create("C", worker_c);
    uthread_run();
    printf("all threads complete\n");
    return 0;
}

Build and run:

$ gcc -Wall -Wextra -g -O2 -o uthreads uthreads.c
$ ./uthreads
A: 0
B: 0
C: 0
A: 1
B: 1
C: 1
A: 2
B: 2
C: 2
A: 3
B: 3
C: 3
A: 4
B: 4
C: 4
all threads complete

The output shows the three user threads interleaving in round-robin order, exactly as the library scheduler intended. Three streams of control are running "at once" — but the kernel has no idea they exist. To the kernel, this is a single-threaded program that happens to spend its time copying register values around.

Measuring the switch cost

Replace the print loop in each worker with an empty loop that calls uthread_yield one million times, and time the total. The user-thread switch cost is:

Total wall-clock for 3 million yields: 0.42 s
Switches per yield:                    ~2 (out and back)
Effective cost per switch:             ~70 ns

Compare this to the process-switch measurement from Lesson 3.5, which was measured in the low microseconds. The user-thread switch is roughly twenty to fifty times cheaper. This is the entire point of the model — and, as the next part of the example shows, it comes at a cost.

Demonstrating the blocking problem

Now modify worker_a so that, after its second iteration, it calls read on standard input. The remaining iterations of worker_b and worker_c should continue to run — but they will not, because read blocks the entire process.

static void worker_a(void) {
    for (int i = 0; i < 5; i++) {
        printf("A: %d\n", i);
        if (i == 1) {
            printf("A: about to read from stdin (will block)\n");
            char buf[16];
            read(0, buf, sizeof(buf) - 1);
            printf("A: got input\n");
        }
        uthread_yield();
    }
    uthread_exit();
}

Rebuild and run. The program prints:

A: 0
B: 0
C: 0
A: 1
A: about to read from stdin (will block)

# ...the program hangs here until you press Enter,
# even though B and C have more iterations to run.

While the program is waiting for input, open another terminal and run ps -L -C uthreads -o pid,tid,stat,comm. You will see a single thread — the kernel knows about only one — in state S (sleeping). The other two user threads are also there, but the kernel does not see them, and it will not resume the process until read returns.

Press Enter. The read returns, the library resumes its schedule, and workers B and C pick up where they left off:

A: got input
B: 1
C: 1
A: 2
B: 2
C: 2
...
all threads complete

The demonstration makes the blocking problem concrete. As long as no thread blocks, three user threads run in round-robin order with the cheapest possible switch cost. As soon as one thread blocks, the whole process stops. This is exactly the trade-off that motivates the kernel-thread model in Lesson 4.3.

06 Interactive activity

Activity · build, run, and stress a user-thread library

Work through the following in your course VM. The goal is to see the user-thread model in action and to observe both its speed and its blocking behaviour directly.

  1. Build the library. Copy the worked-example program into a file called uthreads.c in your lab directory. Compile with the course flags:
    gcc -Wall -Wextra -g -O2 -o uthreads uthreads.c
    Run it several times. Is the interleaving always the same? Why or why not?
  2. Add a fourth worker. Extend the program to create a fourth thread worker_d. Rebuild and run. Observe that the round-robin order now includes D. This is the library scheduler making decisions the kernel cannot see.
  3. Measure the switch cost. Modify the three workers so that instead of printing they call uthread_yield one million times each. Time the run with the shell's time command. Compute the per-switch cost from the elapsed time and the known number of yields. Record this number.
  4. Compare against kernel threads. Write a variant of the program that uses POSIX threads (pthread_create) instead of the user-thread library, with each thread calling sched_yield one million times. Time it. Compare the per-switch cost to the user-thread number.
  5. Demonstrate the blocking problem. Modify worker_a so that it calls read on standard input partway through its loop, as in the worked example. Rebuild and run. Confirm that all three threads stall until input is provided. While the program is blocked, run ps -L -C uthreads -o pid,tid,stat,comm and note the number of kernel-visible threads.
  6. Demonstrate the multicore limitation. Add a CPU-bound worker that runs a long computation without yielding. Rebuild and run. Observe that the other workers do not make progress while the busy worker is running, because the library cannot preempt it. This is the cooperative-multitasking consequence.
  7. Consider a fix. Without implementing it, sketch how you would use a SIGALRM timer or a POSIX timer to add preemption to the library. What would the signal handler need to do? What are the risks of this approach? Write a short paragraph describing the design and its hazards.

Reflect: which measurement best illustrated the performance advantage of user threads over kernel threads? Which demonstration best illustrated the blocking problem? If you had to design a system that used user threads for a modern server, what would you need to add to the model to make it viable?

07 Knowledge check

Check your understanding

Question 1

Why is a user-thread switch faster than a kernel-thread switch?

Show answer
Because a user-thread switch does not involve the kernel at all. The library saves the outgoing thread's registers into its TCB, restores the incoming thread's registers from its TCB, and transfers control — all with ordinary user-space instructions and no trap. A kernel-thread switch requires a trap into the kernel, a scheduler decision, a save of state on the kernel stack, a restore of the incoming state, and a return-from-trap. That sequence costs hundreds to thousands of cycles; a user-space switch is a few dozen. On the same hardware the difference is typically 10–50×.

Question 2

What is the blocking problem, and why does it defeat the purpose of user threads?

Show answer
The blocking problem is that a blocking system call issued by any one user thread blocks the entire process. The kernel sees the process as a single thread of control, so when that thread makes a call that cannot complete immediately, the kernel suspends the whole process. The user-thread library, which lives inside the process, cannot run — so it cannot switch to another user thread. All user threads of the process stop making progress until the blocking call returns. This defeats the purpose of user threads, because the whole point of having multiple threads is that some of them should be able to make progress while others wait.

Question 3

Name three workarounds for the blocking problem and explain the cost of each.

Show answer
Any three of: Non-blocking I/O — replace every blocking call with a non-blocking equivalent, discovering readiness through select or epoll. Cost: every operation in the program (including inside linked libraries) must be rewritten. Wrapper libraries / jacketing — interpose a wrapper that checks before calling and yields if the call would block. Cost: wrappers may be bypassed by statically-linked code that internally blocks. Scheduler activations — the kernel notifies the library before suspending the process, giving the library a chance to switch. Cost: requires kernel cooperation and a more complex kernel interface; never widely deployed. Hybrid many-to-many models — run multiple kernel threads, each carrying several user threads; a block only stalls the kernel thread that blocked. Cost: significant library complexity.

Question 4

Why can user threads of a single process not run on two CPU cores simultaneously?

Show answer
Because the kernel sees only one schedulable entity per process. When the kernel dispatches the process to a CPU core, the whole process runs on that core — including whatever user thread the library has currently scheduled. There is no way for the kernel to dispatch the same process to two cores at once, because it has no notion of the process containing multiple independent streams of execution. To exploit multiple cores, the program must either create multiple processes (each of which the kernel can schedule on its own core) or use kernel-level threads (which the kernel knows about and can schedule across cores independently).

Question 5

Go's goroutines are sometimes described as a modern implementation of the user-thread model. In what sense is this accurate, and in what sense does Go improve on the original?

Show answer
It is accurate in that goroutines are lightweight tasks that the Go runtime schedules in user space, multiplexed onto a smaller number of kernel threads, with a user-space context switch between them. This is exactly the design of the original user-thread libraries. Go improves on the original in three ways. First, the runtime controls the entire program — including every I/O operation — so it can install non-blocking wrappers on every system call, eliminating the blocking problem. Second, the runtime schedules goroutines across multiple kernel threads, so the multicore limitation is gone. Third, the runtime's scheduler is designed for modern workloads (work-stealing across CPUs, efficient handoff between goroutines). The user-thread idea is the same; the implementation has caught up with the hardware.

08 Unit discussion

Discussion prompt

The user-thread model was invented in the 1980s, when single-core machines were the norm and the blocking problem was considered a solvable engineering detail. The multicore era made the model's second fundamental limitation — the inability to run user threads on multiple cores — even more serious. Yet user threads survive, and in fact have re-emerged inside modern language runtimes such as Go and Erlang.

What does this arc suggest about how abstractions move between layers of a system? When a model's original limitations become fatal at one layer, does it simply move to another layer where those limitations can be circumvented — or does it get reinvented from scratch each time? Argue your position using at least one concrete example from this lesson or from your own experience.

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

09 Practice exercise

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

  1. Complete the library. Extend the worked-example library with a proper run queue (a linked list or array-based queue) and a uthread_join function that blocks the caller until a specified thread has terminated. Handle the case where the target thread has already terminated.
  2. Measure switching cost precisely. Write a benchmark that switches between two threads in a tight loop (both threads just yield) ten million times and reports the average cost per switch. Record the number. Then do the same with POSIX threads using sched_yield. Compare the two numbers.
  3. Demonstrate the blocking problem. Build the variant of the worked-example program with a read call inside one of the workers, as in the worked example. Capture the full output. Record how many kernel-visible threads the process has while blocked (use ps -L). Explain in two or three sentences why the other workers cannot run.
  4. Demonstrate the multicore limitation. Write a variant in which one worker runs a heavy CPU-bound loop (for example, summing a large array) and the other workers do short bursts of work. Observe that the short workers do not get to run until the CPU-bound worker yields. Explain why, and what the library would need to do to fix it.
  5. Add non-blocking I/O. Modify the read in the previous exercise to use a non-blocking file descriptor. Before the read, check with poll whether data is available; if not, mark the calling thread as blocked and yield to another. This is the simplest of the workarounds from section 4.6, and implementing it makes the design of the more elaborate workarounds concrete.
  6. Compare with kernel threads. Rewrite the parallel-array-sum example from Lesson 4.1 using POSIX threads. Time the kernel-thread version and the user-thread version on the same data. Which is faster? By how much? On what size workload does the difference become important?
  7. Reflect. In your own words, describe the single most important design decision in a user-thread library, and explain why that decision determines whether the library is suitable for a given workload.

10 Summary

  • A user-level thread is created, scheduled, and managed entirely by a library in the process's own address space. The kernel does not know the threads exist.
  • The library maintains a thread control block (TCB) for each thread, holding the saved register context, a thread-private stack, a state field, and scheduling metadata.
  • A user-thread switch is a pure user-space operation: save registers, restore registers, transfer control. No kernel trap, no mode switch. On Linux the mechanism is the POSIX ucontext family.
  • Because the kernel does not schedule user threads, the library must. Most user-thread libraries are cooperative: the running thread must explicitly yield.
  • The blocking problem is the model's central limitation: a blocking system call in any one user thread blocks the entire process, because the kernel suspends the process and the library cannot run.
  • Workarounds for the blocking problem: non-blocking I/O, wrapper libraries (jacketing), scheduler activations, and hybrid many-to-many models. None is entirely satisfactory.
  • The multicore limitation is the model's second central limitation: user threads of one process cannot run on multiple cores, because the kernel sees only one schedulable entity.
  • User threads have real advantages — extremely fast switches, application-specific scheduling, portability, small per-thread footprint — which keep the model alive in specialised contexts and inside modern language runtimes.
  • Go's goroutines, Erlang's processes, and Rust's async tasks are modern reincarnations of the user-thread idea. The model survived because the runtime controls the entire program, so the blocking and multicore problems can be solved by construction.

Looking ahead

Lesson 4.3 presents the kernel-thread model, which solves both the blocking problem and the multicore limitation by making the kernel aware of each thread. The trade-off is that thread operations now require kernel involvement, which makes them slower than user-thread operations — but far more robust.

11 References

  • Silberschatz, A., Galvin, P. B., & Gagne, G. Operating System Concepts, 10th ed., Chapter 4, Section 4.3.
  • Arpaci-Dusseau, R. H., & Arpaci-Dusseau, A. C. Operating Systems: Three Easy Pieces, Chapter 26. Available online at ostep.org.
  • Draves, R., Bershad, B., Rashid, R., & Dean, R. Using Continuations to Implement Thread Management and Communication in Operating Systems. SOSP, 1991.
  • Anderson, T. E., Bershad, B. N., Lazowska, E. D., & Levy, H. M. Scheduler Activations: Effective Kernel Support for the User-Level Management of Parallelism. ACM TOCS, 1992.
  • Linux man-pages: man 3 makecontext, man 3 swapcontext, man 3 getcontext, man 3 setcontext.
  • GNU Pth project documentation, available at gnu.org/software/pth/.
  • Butenhof, D. R. Programming with POSIX Threads, Chapter 1. Addison-Wesley, 1997.
👤