Guide

A process starts when another process asks the kernel for a copy of itself. It takes on a new program, does its work and exits with a number. Every shell command you type follows this same path. Knowing the steps explains how pipes work, why exit codes look strange and why some processes pile up as zombies.

Your program never touches the kernel directly. When it needs something only the kernel can do, it makes a system call. A system call switches the CPU from user mode into the kernel, the kernel does the work, and control returns.

The big idea

Unix creates processes in two steps.

  1. fork() clones the calling process. The child gets a copy of the parent's memory and open file descriptors. fork() returns twice: the child's PID in the parent, and 0 in the child.
  2. exec() replaces the program in the current process with a new one. The PID and open fds stay the same, unless an fd is marked close-on-exec.

The parent then calls wait() to collect the child's exit status. Between fork and exec, the child can rewire its fds. That is how ls > out.txt and pipes work: the child points fd 1 at a file or pipe before it runs ls.

Walking through fork and wait

This runnable script forks a child that exits with status 3. The parent waits and reads the status.

Python
import os

pid = os.fork()
if pid == 0:                     # the child takes this branch
    os._exit(3)                  # exit with status 3, skipping cleanup
else:                            # the parent takes this branch
    _, status = os.waitpid(pid, 0)
    print("child exited with", os.waitstatus_to_exitcode(status))

Output:

child exited with 3

Trace it. fork() returns the child's PID in the parent and 0 in the child. The child sees 0, runs its branch and exits with 3. The parent is blocked in waitpid, which returns once the child is gone and the status is available.

Here is a cost that is easy to miss. A trivial system call such as getpid() takes around 100 ns on modern hardware, about 100 times a normal function call. That is why buffered I/O exists. printf collects bytes in memory and makes one write() call per few KB, not one per character.

Bash
strace -c -f python3 -c "print('hi')"   # count system calls by type

Exit codes

A process ends by calling exit(code) or by being killed by a signal. By convention, 0 means success and 1 to 255 means failure. When a signal kills a process, the shell reports 128 plus the signal number.

Exit codeMeaning
0Success
1Generic error
130128 + 2: killed by SIGINT (Ctrl-C)
137128 + 9: killed by SIGKILL, often the OOM killer or a container runtime
143128 + 15: killed by SIGTERM

Exit code 137 does not mean your code crashed. It means something outside the process forced it to stop.

Zombies and orphans

When a process exits, the kernel frees its memory but keeps a small entry (PID, exit status, resource usage) until the parent calls wait(). A process in that state is a zombie, shown as Z or <defunct> in ps.

Zombies use no memory or CPU, but each holds a PID. PIDs are finite, and containers often cap them with cgroups. A parent that spawns children and never reaps them leaks PIDs until fork() fails.

If a parent dies first, its children become orphans. The kernel re-parents them to PID 1, or to the nearest "subreaper", which must reap them.

Common mistakes

  • Forking without waiting. The child becomes a zombie and holds its PID. Always wait() or let a subreaper do it.
  • Reading exit code 137 as an app bug. It means SIGKILL. Check for OOM kills and container limits first.
  • Writing one byte per system call. Each write() crosses into the kernel. Use buffered I/O.

Check yourself

  1. Why does the child get 0 back from fork()? So the code can tell which side of the fork it is on. The parent gets the child's PID instead.
  2. What is the exit code for a process killed by Ctrl-C? 130, which is 128 plus signal 2 (SIGINT).

Key takeaways

  • A new process starts with fork() (a copy) and usually calls exec() to run a new program.
  • The child can change its fds between fork and exec. That is how shells build pipes and redirects.
  • An exit code of 128 plus N means the process was killed by signal N.
  • A zombie is an exited process whose parent has not yet called wait().

Further learning

The next page covers the memory each of these processes gets: virtual memory. For the full list of signals and their defaults, see the signal(7) man page.