Operating systems and concurrency
Process lifecycle
How a process is born with fork, takes on a new program with exec, and exits with a code. Why zombies build up when a parent never calls wait.
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.
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, and0in the child.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.
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 3Trace 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.
strace -c -f python3 -c "print('hi')" # count system calls by typeExit 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 code | Meaning |
|---|---|
| 0 | Success |
| 1 | Generic error |
| 130 | 128 + 2: killed by SIGINT (Ctrl-C) |
| 137 | 128 + 9: killed by SIGKILL, often the OOM killer or a container runtime |
| 143 | 128 + 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
- Why does the child get
0back fromfork()? So the code can tell which side of the fork it is on. The parent gets the child's PID instead. - 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 callsexec()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.
Related
Processes, threads and the kernel
What the OS gives every program, and how to see it on a live Linux box. Start here for the process model.
Virtual memory
Why every process sees its own private memory, how the kernel maps it to RAM in 4 KiB pages, why ps shows two memory numbers, and what triggers the OOM killer.
Signals and graceful shutdown
How the kernel tells a process to stop, why SIGTERM can be caught and SIGKILL cannot, and how a service drains work before it exits. Covers the PID 1 trap in containers.