Skip to main content

Abstractions 1: Threads and Processes

Source: UC Berkeley CS162, Fall 2020/2021 - Prof. John Kubiatowicz, Lecture 3

CS162 Lecture 3 - Abstractions 1: Threads and Processes

The previous lecture defined the thread, address space, process, and dual-mode operation. This one takes the programmer's viewpoint: how you actually create threads, what they share, and how you create and run processes with the fork/exec/wait/exit API. It also draws the line between concurrency and parallelism, which is easy to conflate.

Concurrency is not parallelism

Some definitions worth pinning down: multiprocessing is multiple CPUs (cores); multiprogramming is multiple jobs or processes; multithreading is multiple threads per process. Running two threads concurrently only means the scheduler is free to run them in any order and interleave them - each may run to completion or be time-sliced in big or small chunks.

Two timelines: Multiprocessing runs threads A, B, C truly simultaneously on separate cores; Multiprogramming interleaves A, B, C on one core over time
Parallelism (multiprocessing) needs multiple cores and runs threads at the same instant. Concurrency (multiprogramming) interleaves threads on one core. Correct concurrent code must work under every possible interleaving - whether or not it ever runs in parallel.

Why concurrency: overlapping compute and I/O

The practical reason to bother is that operations differ in cost by many orders of magnitude. While one thread waits on a slow operation (disk, network), another can use the CPU. Jeff Dean's "numbers everyone should know" make the gaps vivid.

Latency table: L1 cache 0.5 ns, branch mispredict 5 ns, L2 cache 7 ns, mutex lock/unlock 25 ns, main memory 100 ns, compress 1K 3,000 ns, send 2K over 1 Gbps 20,000 ns, read 1 MB from memory 250,000 ns, datacenter round trip 500,000 ns, disk seek 10,000,000 ns, read 1 MB from disk 20,000,000 ns, packet CA to Netherlands to CA 150,000,000 ns
The latency hierarchy. A disk seek costs 20 million times an L1 cache reference; a cross-world packet, 300 million times. Concurrency exists so the CPU has something to do during those waits.
Latencies worth memorising (approximate)
  • L1 cache reference - 0.5 ns; main memory reference - 100 ns (~200x slower).
  • Mutex lock/unlock - 25 ns.
  • Round trip in the same datacenter - 500,000 ns (0.5 ms).
  • Disk seek - 10,000,000 ns (10 ms); packet CA -> Netherlands -> CA - 150,000,000 ns (150 ms).

Creating threads: the pthreads API

At the programmer's level, threads are created through a library. POSIX threads (pthreads) give you three core calls: pthread_create starts a new thread running a function, pthread_exit ends the calling thread, and pthread_join waits for a thread to finish and collects its result.

pthreads signatures: pthread_create(pthread_t *thread, const pthread_attr_t *attr, void *(*start_routine)(void*), void *arg); pthread_exit(void *value_ptr); pthread_join(pthread_t thread, void **value_ptr)
The three core pthreads calls. create takes the function to run and its argument; exit ends a thread and hands back a value; join blocks until a target thread ends and receives that value.
A pThreads example program that creates several threads, with questions: how many threads are in the program, does main join in creation order, do threads exit in creation order, would rerunning change the result
A first pthreads program, with the questions that matter: how many threads exist, and in what order do they run and finish? The answer - you cannot assume any order - is the whole point of the next unit.

What threads share, and what they don't

Threads in one process share the process's memory, but each still needs its own execution context. The split is exact: shared state is the heap, global variables, and code; per-thread state is the thread control block, saved registers, and its own stack.

Table: Shared State column lists Heap, Global Variables, Code; two Per-Thread State columns each list Thread Control Block (TCB) with Stack Information, Saved Registers, Thread Metadata, and a Stack
What is shared vs per-thread. Heap, globals and code are shared across all threads in the process; the TCB, saved registers and stack belong to each thread individually.

Because every thread has its own stack but they all live in one address space, the address space has to hold several stacks at once.

Address space with Stack 1 and Stack 2 near the top, Heap, Global Data and Code below; notes: two sets of CPU registers, two sets of stacks, and issues about positioning stacks and catching overflow
Two threads, one address space: two stacks carved out of the top, sharing the same heap, globals and code. This raises real questions - how far apart to place the stacks, and how to catch one overflowing into the other.
Shared state is exactly where races live

Everything in the shared column is reachable by every thread at the same time. If two threads write the same global or heap object without coordination, the result depends on the interleaving. This is the setup for the entire Synchronization unit - threads share memory by design, so sharing safely is a problem you must solve, not one you get for free.

System calls: crossing into the kernel

A user program cannot touch hardware directly; it asks the kernel through a system call. A portable OS library exposes a uniform interface, and the same call works across very different hardware underneath.

A funnel: many applications (compilers, word processing, email, web browsers, web servers) narrow through a Portable OS Library and a System Call Interface to a Portable OS Kernel, then fan out to varied hardware (x86, PowerPC, ARM) and networks
System calls are the narrow waist. Many applications above and many hardware platforms below meet at one system-call interface, so a program written once runs on very different machines.

Processes: the heavier unit

Threads share everything; sometimes you want the opposite - full isolation. That is a process. Everything outside the kernel runs inside some process, including the shell, and processes are created and managed by other processes.

Slide 'Processes': how to manage process state (create, exit); everything outside the kernel runs in a process, including the shell; processes are created and managed by processes
The process is the unit of management: you create one, it runs isolated, and it exits. Even the shell that launches your programs is itself just another process.

The OS exposes a small process-management API.

Process Management API: exit (terminate a process), fork (copy the current process), exec (change the program being run by the current process), wait (wait for a process to finish), kill (send a signal to another process), sigaction (set handlers for signals)
The six calls. The two that look surprising are fork (which duplicates the calling process) and exec (which replaces the running program in place) - together they are how every new program gets started on a Unix system.

fork: cloning a process

fork() creates a near-exact copy of the calling process. The child gets its own copy of the address space. The trick is the return value: fork returns twice - once in the parent (returning the child's PID) and once in the child (returning 0).

fork1.c: cpid = fork(); if (cpid > 0) it is the parent process and prints its child's pid; else if (cpid == 0) it is the child process and prints its own pid; else fork failed
fork1.c: one call, two returns. The parent sees the child's PID (a positive number); the child sees 0. That single branch is how one program becomes two.
fork returns twice, and you can't assume who runs first

After fork, both processes continue from the same line with different return values. Which one the scheduler runs first is not defined. Code that assumes the parent (or child) runs first is already broken.

fork_race.c: after fork, the parent loops printing 'Parent' ten times and the child loops printing 'Child'; questions ask what it prints and whether adding sleep() calls would matter
fork_race.c: parent and child each loop and print. The interleaving of their output is nondeterministic - and adding sleep() changes the timing but never makes the ordering guaranteed.

exec: running a different program

fork alone only ever runs the same program. To run a different one, the child calls exec, which replaces its address space with a new program image while keeping the same process (and PID). The fork-then-exec pair is how a shell launches a command.

Slide 'Running Another Program': with threads you call pthread_create to run a separate function; with processes the equivalent is spawning a new process running a different program; how can we do this?
Running a different program is the process analogue of pthread_create. The mechanism is fork (make a copy) followed by exec (replace the copy's program) - the answer to the slide's closing question.
The fork/exec split
  • fork duplicates the current process; both parent and child return from the same call with different values.
  • exec replaces the current program image with a new one, keeping the same process.
  • A shell runs a command by fork then exec in the child, while the parent waits - separating "make a new process" from "choose what it runs".

Bootstrapping

If every process is created by another process, where does the first one come from? The kernel starts it directly at boot - the init process - and every other process on the system descends from it.

Slide 'Bootstrapping': if processes are created by other processes, how does the first start? The first process is started by the kernel (configured as an argument before the kernel boots, the 'init' process); after this, all processes are created by other processes
The bootstrap: the kernel hand-creates the first process (init) at boot. From then on the rule holds without exception - every process is forked from an existing one.

Recap

  • Concurrency is not parallelism: concurrency is interleaving on one core; parallelism needs multiple cores. Correct code must survive every interleaving.
  • Threads are created with pthreads (pthread_create / exit / join) and share the heap, globals and code while keeping their own stack and registers.
  • A system call is the controlled crossing from user code into the kernel.
  • A process is the isolated, heavier unit, managed with fork/exec/wait/exit.
  • fork copies a process (returning twice); exec replaces its program; together they start every new program. The kernel bootstraps the first process (init).
  • Next: the abstraction those processes touch constantly - files and I/O.