← All chapters
Chapter 8· 19 min read · illustrated

The Process & the PCB

What a running program actually is to the kernel, and the record it keeps to manage one

The last chapter ended on a definition: a process is a program brought to life — a private address space, a set of CPU registers, some open files, and an entry in the kernel’s bookkeeping. This chapter takes that definition apart and rebuilds it from the inside. We want to know precisely what the kernel stores about each running program, how it flips one on and off the CPU, and how new processes are born and eventually die.

Everything here rests on one data structure: the Process Control Block, the per-process record the kernel keeps for every process alive on the machine. Once you can picture the PCB — the PID, the saved registers, the memory maps, the open-file table, the state — the rest of the process model falls out of it almost mechanically. Context switching is saving one PCB and loading another. Scheduling is choosing which PCB runs next. A zombie is a PCB that has not been cleaned up yet.

Then we get concrete with the four system calls that define the Unix process model — fork, exec, wait, and exit — and write real C that creates children, replaces process images, and reaps the dead. This is the exact machinery a shell uses every time you type a command, that a web server uses to handle a request, and that Postgres uses to isolate every client connection. Understanding it is understanding how software actually runs.

01

What a process really is

A program and a process are not the same thing, and confusing them is the root of most beginner errors here. A program is passive: it is a file on disk, a sequence of instructions and initial data sitting there whether or not anyone runs it. A process is active: it is that program in execution, with a current point of activity — the program counter marking which instruction runs next — plus everything the OS wraps around it to keep it running.

The clearest way to feel the difference is that one program can become many processes. Open three terminal windows and each runs its own copy of the shell: one program (bash on disk), three processes, each with its own memory, its own place in the code, its own open files. The recipe is shared and read-only; every cook working from it has their own kitchen, their own half-chopped onions, their own place in the steps.

Program
The passive thing: an executable file on disk — instructions and initial data, doing nothing until run.
Process
The active thing: a program in execution, with a current activity and its own resources.
Current activity
Where execution is right now — captured by the program counter and the rest of the CPU registers.
Owned resources
What the process holds while it runs: an address space, open files, and its CPU register state.

Remember: Program is the noun on disk; process is the verb in motion. The same file, run twice, is two independent processes that cannot see each other’s memory.

Tap to enlarge
02

What the process owns in memory

The biggest resource a process owns is its address space — the private range of memory it believes it has all to itself. We drew this in the previous chapter, and it is worth holding in view because the PCB has to describe every piece of it. From low addresses up: the text segment holds your machine code, then read-only data, then initialised globals, then the zero-filled BSS. Above those the heap grows upward as you allocate; at the top the stack grows downward as you call functions.

Two facts make this an OS story and not just a memory-layout picture. First, the space is private: process A cannot read process B’s address space, because the kernel gives each its own mapping from virtual addresses to physical RAM. Second, the space is an illusion the kernel maintains — a topic we open fully in the memory-management part. For now, treat the address space as the single largest thing a process owns, and something the kernel must record so it can protect it and, later, hand it off during a context switch.

  • Text: the process’s code, read-only and often shared between copies of the same program.
  • Data + BSS: global and static variables, initialised and zero-filled respectively.
  • Heap: dynamic memory from malloc/new, growing upward on demand.
  • Stack: local variables and call frames, growing downward; one per thread.

Why engineers care: Process isolation is exactly this private address space. It is why a crash in one service does not corrupt another, and why sharing data between processes needs deliberate IPC — a wall the kernel enforces on purpose.

Tap to enlarge
03

The Process Control Block

For every process alive on the machine, the kernel keeps one record: the Process Control Block, or PCB. On Linux this is a large C struct called task_struct; the name varies by OS but the idea is universal. The PCB is the process, from the kernel’s point of view — it is the complete answer to the question "everything I need to know to manage, pause, resume, and account for this program".

The fields group into a few clear families. There is identity (the PID and the parent’s PID). There is CPU state — the saved registers and program counter, which is what makes it possible to freeze a process mid-instruction and thaw it later. There is memory information (pointers to the process’s page tables that define the address space). There is the open-file table (which files, sockets, and pipes this process has open). There is scheduling information (priority, how much CPU it has used, which queue it sits in). And there is the family tree (links to parent and children).

PID / PPID
The process ID that uniquely names this process, and the parent process ID that names who created it.
Saved registers + PC
A snapshot of CPU state so the process can be paused and later resumed exactly where it stopped.
Memory maps
Pointers to the page tables describing this process’s address space — text, data, heap, stack.
Open-file table
The per-process list of open file descriptors: files, sockets, pipes, and their positions.
Scheduling info
Priority, CPU time consumed, and scheduling class — what the scheduler reads to pick who runs next.
Process table
The kernel-wide collection of all PCBs, so the OS can find and manage every process by PID.

Remember: When someone says "the kernel switched to another process", they mean it saved the current PCB’s registers and loaded another PCB’s. The PCB is where a paused process lives while it waits its turn.

Tap to enlarge
04

Process states & the state diagram

A process is not always running — most of the time it is not. At any instant each process is in one of a small set of states, and the kernel records that state in the PCB. The classic five are: new (being created), ready (able to run, just waiting for a CPU), running (currently executing on a CPU), waiting or blocked (cannot proceed until some event happens, usually I/O), and terminated (finished, awaiting cleanup).

The interesting part is the transitions, because each one is caused by a specific event. new → ready happens when the OS admits the process. ready → running is a dispatch: the scheduler picks it and loads its PCB onto a CPU. running → ready is a preemption: a timer interrupt fires and the scheduler takes the CPU back to give someone else a turn. running → waiting happens when the process asks for something slow — reads a file, waits on a lock — and the kernel parks it. waiting → ready fires when that event completes, making it runnable again. running → terminated is exit.

Ready
Runnable and waiting only for a free CPU; the scheduler chooses from the set of ready processes.
Running
Actually executing on a CPU right now — on an N-core machine, at most N processes are running at once.
Waiting / blocked
Cannot make progress until an event (I/O completion, a signal, a lock) occurs; not competing for the CPU.
Preemption
The kernel forcibly moving a running process back to ready, usually on a timer, so others get a turn.

Why engineers care: A process eating CPU sits in running/ready; a "hung" one is usually stuck in waiting on some I/O or lock. Tools like top show this directly — the state column (R, S, D, Z) is literally this diagram, and it tells you whether to look at your CPU or your I/O.

Tap to enlarge
05

Creating a process: fork()

In the Unix model there is exactly one way to create a new process: fork(). It is one of the strangest-looking calls you will meet, because it returns twice. You call fork() once, and the kernel makes a near-duplicate of the calling process — the child — that is almost identical to the parent: same code, same open files, a copy of the same memory. Then both processes return from the same fork() call and continue running the same program from the same line.

How does each copy know who it is? By fork’s return value, and this convention is the whole trick. In the parent, fork() returns the PID of the new child (a positive number). In the child, fork() returns 0. On failure, it returns -1 in the parent and no child is created. So the standard pattern is an if on the return value, giving each process a different branch to run.

fork returns twice: the child sees 0, the parent sees the child’s PID.c
#include <stdio.h>
#include <unistd.h>   /* fork, getpid */

int main(void) {
    pid_t pid = fork();               /* one call, two returns */
    if (pid < 0) {
        perror("fork");               /* creation failed */
    } else if (pid == 0) {
        printf("child:  my pid=%d\n", getpid());
    } else {
        printf("parent: child pid=%d\n", pid);
    }
    return 0;
}

Copying a whole address space sounds ruinously expensive, and once it was. Modern kernels avoid it with copy-on-write (COW): after fork, parent and child share the same physical memory pages, marked read-only. Nothing is actually copied until one of them writes to a page, at which point the kernel duplicates just that page. Since a child very often calls exec immediately (next section) and throws the memory away, this makes fork cheap in practice — you rarely pay for the copy at all.

Gotcha: After fork, the order in which parent and child run is not defined — either may print first. And every open file descriptor is shared into the child, which is exactly how pipes get wired up, but also a source of surprises if you forget the child inherited your files.

Tap to enlarge
06

Running a new program: exec()

fork gives you a second copy of the same program, but usually you want to run a different program. That is the job of the exec family of calls. exec does not create a new process; it replaces the current process’s image — its code, data, heap, and stack are all thrown away and rebuilt from a new executable on disk. The PID stays the same, the open files (by default) stay open, but the program running is now something else entirely. On success, exec never returns, because the code that called it no longer exists.

Put fork and exec together and you have the fork-and-exec pattern that runs essentially every command on a Unix system. The shell forks a child; the child calls exec to become the program you asked for (ls, gcc, python); the parent shell waits for it to finish. Splitting creation (fork) from program-loading (exec) is a deliberate design choice — it gives the child a window, after the fork but before the exec, to set things up: redirect its output, close descriptors, change directory. That window is how shell redirection and pipes are implemented.

The fork+exec pattern a shell uses to run a command.c
#include <stdio.h>
#include <unistd.h>   /* fork, execvp */
#include <sys/wait.h> /* wait */

int main(void) {
    pid_t pid = fork();
    if (pid == 0) {                       /* child */
        char *argv[] = {"ls", "-l", NULL};
        execvp("ls", argv);               /* becomes ls */
        perror("execvp");                 /* only if exec failed */
        _exit(127);
    }
    wait(NULL);                           /* parent waits */
    return 0;
}

Remember: fork = "give me another process"; exec = "turn this process into a different program". The line after a successful execvp is unreachable — if it runs, exec failed, which is why we check with perror right there.

Tap to enlarge
07

Waiting & exit: reaping children

When a process finishes, it calls exit() (or returns from main, which calls exit for it), passing a small integer exit status — by convention 0 for success and non-zero for failure. But the process does not fully vanish at that moment. The kernel keeps a tiny remnant of it around — mainly its PID and exit status — until the parent asks for it. Collecting that status is called reaping, and the parent does it with wait() or waitpid().

wait(NULL) blocks the parent until any one of its children terminates, then returns. waitpid(pid, &status, 0) waits for a specific child and fills in status, which you decode with macros: WIFEXITED(status) tells you it exited normally, and WEXITSTATUS(status) extracts the exit code it passed. This is exactly how a shell knows whether the command you ran succeeded, and it is where the shell’s $? variable comes from.

Parent waits for a child and reads its exit status.c
#include <stdio.h>
#include <stdlib.h>   /* exit */
#include <unistd.h>   /* fork */
#include <sys/wait.h> /* waitpid, WIFEXITED */

int main(void) {
    pid_t pid = fork();
    if (pid == 0)
        exit(42);                          /* child status */

    int status;
    waitpid(pid, &status, 0);              /* block till it ends */
    if (WIFEXITED(status))
        printf("child exited with %d\n", WEXITSTATUS(status));
    return 0;
}

Why engineers care: The exit status is the whole basis of shell scripting and CI: && and || chain commands on it, and a non-zero exit fails a pipeline. If your program returns the wrong code — 0 on failure — automation downstream will silently believe it worked.

Tap to enlarge
08

Zombies & orphans

Two things can go wrong with the parent-child lifecycle, and both have vivid names. A zombie is a child that has terminated but whose parent has not yet called wait to reap it. The child is dead — it holds no memory or CPU — but the kernel must keep its PCB remnant (PID and exit status) alive so the answer is there when the parent finally asks. In ps you see it as state Z and the tag <defunct>.

A single short-lived zombie is harmless. The danger is a long-running parent that forks many children and never waits: each dead child leaves a zombie holding a PID, and because PIDs are a finite resource, a program that leaks them steadily can exhaust the process table until the machine cannot fork anything new. This is a real production failure mode for badly written servers and supervisors.

An orphan is the mirror image: a child whose parent exits first, while the child is still running. The child is not in trouble — the kernel simply reparents it, handing it to a special long-lived process (traditionally init, PID 1, or a subreaper). That new parent’s job includes calling wait, so when the orphan eventually exits it is reaped cleanly and never becomes a lasting zombie.

A zombie seen in ps: dead, but still holding a PID until reaped.bash
$ ps -o pid,ppid,state,comm
  PID  PPID S COMMAND
 4123  4120 Z sleep <defunct>   # Z = zombie, waiting to be reaped
 4120  2891 S server            # its parent, still running

Gotcha: The fix for zombies is not to kill them — they are already dead. You fix the parent: make it call wait/waitpid, or handle SIGCHLD, so every child it forks is eventually reaped. In containers this is why PID 1 must be a real init that reaps, or zombies pile up.

Tap to enlarge
09

Process vs thread & why engineers care

Everything so far has treated a process as a single line of execution. But a process can contain several threads — independent streams of execution that share the same address space. That is the pivotal contrast: separate processes each get their own private memory and isolation, while threads inside one process share the heap, the globals, and the open files, and differ only in their own stack and registers. Sharing makes threads cheap and fast to communicate, but it is exactly what makes concurrency bugs possible. We devote the whole next chapter to threads.

You can inspect all of this on a live system without any C. ps shows the process table with PID, PPID, and state; on Linux the /proc filesystem exposes each process’s PCB as readable files — /proc/PID/status has its state and memory, /proc/PID/fd lists its open descriptors, /proc/PID/maps shows the address space. When you debug a stuck or bloated service, this is where you look first.

Linux exposes each process’s kernel record under /proc.bash
$ ps -o pid,ppid,state,comm -p 4120
  PID  PPID S COMMAND
 4120  2891 S server
$ ls /proc/4120/          # the PCB, as files
cmdline  fd/  maps  status  task/  wchan
  • fork is cheap because of copy-on-write, so fork-per-request servers (classic Apache prefork, CGI) are viable — but each process carries full isolation overhead.
  • Thread pools reuse a few processes and many threads to avoid even that cost, at the price of shared-memory bugs to guard against.
  • Postgres uses one OS process per client connection — strong isolation, a crash in one backend cannot corrupt another.
  • MySQL (InnoDB) uses one thread per connection inside a single process — lighter weight, shared caches, but a bug can affect the whole server.

Depth ahead: Process-per-connection vs thread-per-connection is a real architectural fork in the road, and both choices trace straight back to this chapter. Next we open the process up and study the threads inside it — and the synchronization the shared address space demands.

Tap to enlarge