Foundations · Lesson 1/8

Processes & Threads — Programs Come Alive

40 min 1 quiz Graded labs: 3

What you'll learn

  • The difference between a program and a process
  • What the OS remembers about every process (its PCB) and the five states a process moves through
  • How fork, exec and wait create and clean up processes
  • What threads are, what they share, and what they keep private
  • How to read a ps-style process table and spot zombies

By the end you will have written a real fork/wait program in C and a process-state simulator.

A recipe is not a meal 🍳

A program is a recipe: instructions sitting in a book (a file on disk). Nothing happens until somebody actually cooks.

A process is a cook who is cooking right now. The cook has a private kitchen: their own counter space, their own ingredients, and a bookmark showing which step of the recipe they are on. Run the same recipe twice and you get two cooks in two separate kitchens, each unaware of the other.

The operating system is the restaurant manager. It has only a few stoves (CPU cores) and many cooks, so it decides who gets a stove and for how long. To do that it keeps a clipboard for every cook.

What a process owns

  • An address space: its private view of memory, split into the code (text), data (globals), heap (memory you malloc) and stack (function calls and local variables)
  • CPU context: the registers and the program counter, the "bookmark" in the recipe
  • Resources: open files, sockets, the current directory, the user it runs as

The kernel stores this bookkeeping in a process control block (PCB), which also holds the PID (process id), the parent PID, the state and scheduling information. Every process has a parent: on Linux the first process (PID 1, usually systemd) starts everyone else.

The life of a process

A process is not always running. At any moment it is in one of these states:

              admit             dispatch
   NEW ───────────────▶ READY ───────────────▶ RUNNING ────exit────▶ TERMINATED
                          ▲  ◀──────preempt───────┘  │
                          │                          │ block (wait for I/O)
                          │ wake (I/O finished)      ▼
                          └─────────────────────── WAITING
  • NEW: being created
  • READY: could run, waiting for a CPU
  • RUNNING: executing on a CPU right now (at most one per core)
  • WAITING (also called blocked): cannot continue until something happens, such as a disk read finishing or a key being pressed
  • TERMINATED: finished, waiting to be cleaned up

Notice what is not allowed: a WAITING process never jumps straight to RUNNING. When its I/O finishes it becomes READY and must queue for the CPU again. Switching the CPU from one process to another is called a context switch: the kernel saves the old process's registers into its PCB and loads the new one's.

A table of allowed transitions is all it takes to model a process lifecycle. Try changing a step to see the error for an illegal move.

# A process moves through these states; the OS keeps the current one in the PCB.
TRANSITIONS = {
    "NEW": ["READY"],
    "READY": ["RUNNING"],
    "RUNNING": ["READY", "WAITING", "TERMINATED"],
    "WAITING": ["READY"],
    "TERMINATED": [],
}

def move(state, new_state):
    if new_state not in TRANSITIONS[state]:
        raise ValueError(f"{state} -> {new_state} is not allowed")
    print(f"{state:<10} -> {new_state}")
    return new_state

state = "NEW"
for step in ["READY", "RUNNING", "WAITING", "READY", "RUNNING", "TERMINATED"]:
    state = move(state, step)

try:
    move("WAITING", "RUNNING")  # a blocked process must become READY first
except ValueError as err:
    print("Refused:", err)

Creating processes: fork, exec, wait

On Unix-like systems a new process is born by cloning. fork() creates a child that is a copy of the caller: same code, same variables, same open files. The magic is that fork returns twice, once in each process:

  • in the parent it returns the child's PID (a positive number)
  • in the child it returns 0
  • on failure it returns -1 and no child exists
#include <stdio.h>
#include <stdlib.h>
#include <sys/wait.h>
#include <unistd.h>

int main(void) {
    pid_t pid = fork();
    if (pid == 0) {
        printf("child: hello\n");
        exit(7);                      /* child finishes with status 7 */
    }
    int status;
    waitpid(pid, &status, 0);         /* parent sleeps until the child ends */
    printf("child exited with %d\n", WEXITSTATUS(status));
    return 0;
}

Three calls form the Unix process toolkit:

  • fork() clones the current process
  • exec() (a family: execl, execvp, ...) replaces the current process's code with a different program. The PID stays the same. A shell runs ls by doing fork and then exec("ls") in the child
  • wait() / waitpid() lets a parent collect a child's exit status

Zombies and orphans

When a child exits, the kernel keeps a tiny record (its PID and exit status) until the parent calls wait. A child in that "dead but not yet collected" state is a zombie (Z in ps). It uses no CPU, and you cannot kill it, because it is already dead; the fix is for the parent to wait (or for the parent to exit, after which PID 1 adopts and reaps the zombie). An orphan is the opposite case: a child whose parent exited first. It is simply adopted by PID 1 and keeps running.

Careful: buffered output and fork

When stdout is a pipe or file, C's printf collects text in a buffer and writes it later. fork copies that unwritten buffer too, so the child can print the parent's old lines a second time when it exits. Call fflush(stdout) before fork. You will meet this bug in the first lab.

Lab exercise 1

CGraded in Code Lab

Write a C program that spawns children one at a time and reaps each before starting the next, so the output order is always the same.

Input: one integer n (0 to 10).

Behaviour:

  1. Print parent: starting n children
  2. For i from 1 to n: fork a child. The child prints child i: running and exits with status i * 10. The parent waits for that child and prints parent: child i exited with status S (use WEXITSTATUS)
  3. Finally print parent: all children reaped

Example (input 2):

parent: starting 2 children
child 1: running
parent: child 1 exited with status 10
child 2: running
parent: child 2 exited with status 20
parent: all children reaped

Watch out: the output goes through a pipe, so it is buffered. Without a flush, children re-print the parent's pending lines.

Visible tests (5)
  • Three children
  • A single child
  • Zero children: no fork at all
  • Five children (no duplicated lines)
  • Upper bound: ten children, last status is 100
Example run
Input
3
Expected output
parent: starting 3 children
child 1: running
parent: child 1 exited with status 10
child 2: running
parent: child 2 exited with status 20
child 3: running
parent: child 3 exited with status 30
parent: all children reaped
Starter code
#include <stdio.h>
#include <stdlib.h>
#include <sys/wait.h>
#include <unistd.h>

int main(void) {
    int n;
    if (scanf("%d", &n) != 1) return 1;
    printf("parent: starting %d children\n", n);

    for (int i = 1; i <= n; i++) {
        // TODO 1: flush stdout before every fork (see hint 1)

More lines in Code Lab: 8

Try in Code Lab

Hints and instant feedback are in Code Lab. Optional practice: it does not block finishing the lesson.

JavaScript and Python also run in your browser without an account; other languages use the secure server runner and need a free account.

Threads: many hands, one kitchen

Back in the kitchen: instead of hiring a second cook with a whole new kitchen, what if two cooks share one kitchen? They share the counter and ingredients, but each follows their own step of the recipe. That is a thread.

A process can contain several threads. They run in the same address space:

  • Shared: code, global variables, the heap, open files
  • Private to each thread: its own stack, its own registers and program counter, its own thread id
#include <pthread.h>
#include <stdio.h>

void *work(void *arg) {
    printf("hello from thread %d\n", *(int *)arg);
    return NULL;
}

int main(void) {
    pthread_t t;
    int id = 1;
    pthread_create(&t, NULL, work, &id);  /* start a thread */
    pthread_join(t, NULL);                /* wait for it, like wait() for processes */
    return 0;
}

Processes vs threads

  • Creating and switching between threads is cheaper: no new address space is needed
  • Communicating is easy for threads (just read and write shared memory) but needs great care: two threads changing the same variable at the same time cause a race condition. Processes must use explicit tools such as pipes or shared-memory segments
  • Isolation is weaker with threads: a crash (say, a bad pointer) in one thread takes down the whole process, while a crashed process leaves its siblings alone

Real programs mix both: a web browser typically uses one process per tab (isolation) and many threads inside each (speed). The next topics, scheduling and synchronization, build on this picture.

A copy is private: the parent never sees the child's change. A shared object is visible to everybody, which is both the power and the danger of threads.

// fork() gives the child a COPY of the parent's memory; threads share ONE memory.
const parentMemory = { counter: 0 };

// "Process": the child works on its own copy (real fork is copy-on-write, same effect)
const childMemory = structuredClone(parentMemory);
childMemory.counter += 10;
console.log("after the child process changes its copy:");
console.log("  parent sees", parentMemory.counter, "| child sees", childMemory.counter);

// "Threads": both hold a reference to the very same object
const sharedMemory = { counter: 0 };
const threadA = sharedMemory;
const threadB = sharedMemory;
threadA.counter += 10;
console.log("after thread A changes the shared memory:");
console.log("  thread B sees", threadB.counter);

Lab exercise 2

PythonGraded in Code Lab

Build a tiny process lifecycle checker for a single-CPU machine.

Input: one event per line, <event> <process> (for example dispatch P1). Blank lines are ignored but still count when numbering lines (the first line is line 1).

Legal transitions:

  • new: creates the process in state NEW (the process must not exist yet)
  • admit: NEW to READY
  • dispatch: READY to RUNNING
  • preempt: RUNNING to READY
  • block: RUNNING to WAITING
  • wake: WAITING to READY
  • exit: RUNNING to TERMINATED

Rules:

  • Anything else (including unknown events and processes that were never created) prints line N: illegal EVENT PROCESS in state STATE and changes nothing. A process that does not exist has the state NONE.
  • A legal dispatch while another process is RUNNING prints line N: CPU busy, cannot dispatch PROCESS and changes nothing.
  • At the end print PROCESS: STATE for every process, in the order the processes were first created.

Example:

new P1
new P2
admit P1
admit P2
dispatch P1
dispatch P2
block P1
dispatch P2
wake P1
exit P2

prints

line 6: CPU busy, cannot dispatch P2
P1: READY
P2: TERMINATED
Visible tests (6)
  • Example from the prompt (CPU busy)
  • Illegal events and unknown processes
  • Empty input prints nothing
  • Full lifecycle with preemption and blank lines
  • A READY process cannot exit directly
  • Two CPUs are not allowed: dispatch is refused until preempt
Example run
Input
new P1
new P2
admit P1
admit P2
dispatch P1
dispatch P2
block P1
dispatch P2
wake P1
exit P2
Expected output
line 6: CPU busy, cannot dispatch P2
P1: READY
P2: TERMINATED
Starter code
import sys

# (event, current state) -> next state
TRANSITIONS = {
    ("admit", "NEW"): "READY",
    # TODO 1: add the remaining legal transitions (dispatch, preempt, block, wake, exit)
}

states = {}  # process name -> state; a dict keeps first-created order

for number, line in enumerate(sys.stdin.read().splitlines(), start=1):
    parts = line.split()

More lines in Code Lab: 9

Try in Code Lab

Hints and instant feedback are in Code Lab. Optional practice: it does not block finishing the lesson.

JavaScript and Python also run in your browser without an account; other languages use the secure server runner and need a free account.

Reading the process table

On Linux, ps lists processes. A typical listing has columns for the PID, the PPID (parent PID), the STAT (state) and the command:

  PID  PPID STAT COMMAND
    1     0 Ss   systemd
  801     1 Ssl  dockerd
  955   801 Z+   worker
 1300     1 R+   ps

The first letter of STAT is the state:

  • R: running or ready (on the run queue)
  • S: sleeping, waiting for an event (interruptible: a signal can wake it)
  • D: uninterruptible sleep, usually waiting for disk or other hardware I/O. It cannot be interrupted, which is why a stuck D process can resist even kill -9
  • T: stopped (for example with Ctrl+Z)
  • Z: zombie, finished but not yet reaped by its parent
  • I: idle kernel thread

Extra characters after the letter add detail (s = session leader, l = multi-threaded, + = in the foreground). Process 955 above is a zombie whose parent is 801: the fix belongs in the parent (801 must wait for its children), not in a kill of 955.

Next you will write a shell pipeline that summarises such a table, the kind of one-liner every sysadmin writes.

Lab exercise 3

BashGraded in Code Lab

Summarise a ps-style table read from stdin with a shell pipeline.

Input: a header line PID PPID STAT COMMAND followed by one process per line. The command may contain spaces. The state of a process is the first letter of its STAT column (Ss and S+ are both S).

Output:

  1. One line <letter>: <count> per state, sorted alphabetically by letter
  2. A last line zombies: <pids> listing the PIDs of every process whose state is Z, in input order and separated by single spaces, or zombies: none

Example:

PID PPID STAT COMMAND
1 0 Ss init
212 1 S sshd
340 212 R+ bash
341 340 Z defunct

prints

R: 1
S: 2
Z: 1
zombies: 341

A table with only the header (or empty input) prints just zombies: none.

Visible tests (5)
  • Example from the prompt
  • Several states and two zombies
  • Header only
  • Empty input
  • No zombies; commands with spaces
Example run
Input
PID PPID STAT COMMAND
1 0 Ss init
212 1 S sshd
340 212 R+ bash
341 340 Z defunct
Expected output
R: 1
S: 2
Z: 1
zombies: 341
Starter code
#!/usr/bin/env bash
# Read the whole ps-like table from stdin.
table=$(cat)

# TODO 1: print one line "<letter>: <count>" per process state, sorted by letter.
#   Idea: skip the header, take the first character of column 3,
#   then use sort | uniq -c and reshape the result with awk.

# TODO 2: print "zombies:" followed by the PID of every process whose STAT starts with Z,
#   or "zombies: none" when there are none.
echo "zombies: none"
Try in Code Lab

Hints and instant feedback are in Code Lab. Optional practice: it does not block finishing the lesson.

JavaScript and Python also run in your browser without an account; other languages use the secure server runner and need a free account.

Quick quiz

Question 1 of 5

What does fork() return inside the newly created child process?

Question 2 of 5

Which of these is private to each thread of a process rather than shared?

Question 3 of 5

A process has just asked to read a file from a slow disk. Which state is it in until the data arrives?

Question 4 of 5

ps shows a process in state Z that uses no CPU. What is the correct fix?

Question 5 of 5

Why is switching between two threads of the same process usually cheaper than switching between two processes?

Summary

  • A program is a file; a process is a running program with its own address space, CPU context and resources, tracked by the kernel in a PCB
  • States: NEW, READY, RUNNING, WAITING, TERMINATED. A blocked process returns to READY, never straight to RUNNING
  • fork clones (returns 0 in the child), exec replaces the program, wait collects the exit status. An uncollected child is a zombie
  • Threads share the heap, globals and files but keep their own stack and registers: cheaper and easier to share data, but with races and weaker isolation
  • You wrote a fork/wait program, a lifecycle checker and a ps summary pipeline

Practice: read man 2 fork and man 2 waitpid, then try ps -eo pid,ppid,stat,comm | head on a Linux machine. Next: CPU scheduling, how the OS picks which READY process gets the CPU.

Practice

Reading is not learning until you run something. Pick one.

Graded lab exercises