Operating Systems Module 2 – Processes: creation, life cycle, signals and IPC
Exit, zombies, orphans and daemons
How a process ends and what its parent learns, why zombies exist and how to collect them, who adopts orphans, and how sessions let daemons detach.
What you will learn
- Explain how a process terminates and what its parent learns from the status word
- Create a zombie process, show that it still exists, and collect it with waitpid
- Describe how an orphan is adopted by PID 1, or on Linux by a subreaper
- Explain how process groups and sessions let a shell signal a whole job and let a daemon detach
Before you start
What you’ll learn · Before you start
What you will learn
- Explain how a process terminates and what its parent learns from the status word
- Create a zombie process, show that it still exists, and collect it with waitpid
- Describe how an orphan is adopted by PID 1, or on Linux by a subreaper
- Explain how process groups and sessions let a shell signal a whole job and let a daemon detach
Before you start
On this page
A child that calls exit(256) tells its parent that it succeeded, with status 0, and a child that has exited can stay
in the process table indefinitely: both happened in this lesson’s recorded runs. When a process ends, the kernel frees
almost everything it had, but keeps a small record until the parent asks how the child ended.
This lesson follows a process from its last instruction to the removal of its record: what a parent learns, why a zombie process: A process that has exited but whose exit status its parent has not collected yet. It keeps its process ID and a small entry in the process table, but no memory. exists and how to collect it, who becomes the parent of an orphan, shown by a model in 14 languages, and how process groups and sessions let a shell stop a whole pipeline with one key and let a daemon run on without a terminal.
A process ends, and its parent learns how
A process ends in one of two ways. It ends normally when it calls exit or _exit, or returns from main. It is
ended by a signal when it receives one whose action is to terminate it, SIGTERM or SIGKILL for example. Either way, the
kernel does the same work: it releases the process’s memory, closes its files and sends SIGCHLD: The signal the kernel sends a parent when one of its children exits, stops or continues. Its default action is to ignore it, but a handler can use it to collect children.
to its parent, and it keeps the process’s ID and its exit status: The number a process passes to exit or _exit, or returns from main. Its parent collects it with wait or waitpid, which report only its low 8 bits, so exit(256) reads as 0. More in “Creating processes: fork, exec and wait” in its row of the process
table. This program ends six children in different ways and prints the raw status word each parent receives, then what
the W* macros make of it:
#include <signal.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/wait.h>
#include <unistd.h>
static const char *signal_name(int sig) {
return sig == SIGTERM ? "SIGTERM" : sig == SIGKILL ? "SIGKILL" : "another";
}
// Starts a child that ends the way `how` says, and prints what the parent
// learns from waitpid: the raw status word, then what the W* macros make of it.
static void learn(const char *how, int code) {
fflush(stdout);
pid_t pid = fork();
if (pid < 0) {
perror("fork");
return;
}
if (pid == 0) {
if (strcmp(how, "raise(SIGTERM)") == 0) {
raise(SIGTERM); // no handler, so the default action ends the process
}
if (strcmp(how, "kill -9 by the parent") == 0) {
pause(); // wait here until the parent's signal arrives
}
exit(code);
}
if (strcmp(how, "kill -9 by the parent") == 0) {
kill(pid, SIGKILL);
}
int status = 0;
waitpid(pid, &status, 0);
printf("%-22s status 0x%04x: ", how, (unsigned)status);
if (WIFEXITED(status)) {
printf("exited with %d\n", WEXITSTATUS(status));
} else if (WIFSIGNALED(status)) {
printf("killed by signal %d (%s)\n", WTERMSIG(status),
signal_name(WTERMSIG(status)));
}
}
int main(void) {
learn("exit(0)", 0);
learn("exit(3)", 3);
learn("exit(255)", 255);
learn("exit(256)", 256);
learn("raise(SIGTERM)", 0);
learn("kill -9 by the parent", 0);
return 0;
} Output
exit(0) status 0x0000: exited with 0 exit(3) status 0x0300: exited with 3 exit(255) status 0xff00: exited with 255 exit(256) status 0x0000: exited with 0 raise(SIGTERM) status 0x000f: killed by signal 15 (SIGTERM) kill -9 by the parent status 0x0009: killed by signal 9 (SIGKILL)
Recorded with Apple clang version 21.0.0 (clang-2100.3.34.2) on macOS 26 arm64. To run it yourself: clang -std=c23 -O2 -Wall -Wextra -Werror -pedantic -fsanitize=address,undefined exit_statuses.c -o exit_statuses && ./exit_statuses
The status word packs both cases into one integer. On macOS, as XNU’s sys/wait.h defines the macros, and on Linux,
in the GNU C Library’s bits/waitstatus.h, the low 7 bits hold the signal that ended the child, 0 for a normal exit,
and the exit status sits in bits 8 to 15: exit(3) gave 0x0300, SIGTERM 0x000f. Two consequences follow from the
8 bits. An exit status above 255 is cut to its low 8 bits, as POSIX says for wait and waitpid, so exit(256) arrived
as 0, a success, and exit(-1) arrives as 255. A shell has to report a child killed by a signal as a number too:
POSIX requires one greater than 128, and bash uses 128 plus the signal’s number, so SIGTERM appears as 143.
Returning a count as the exit status
A program that returns the number of failed items as its exit status reports success for exactly 256 failures, and wrong counts above 255. Return 0 for success and a small number, 1 to 125, for each kind of failure, and print counts instead.
Checkpoint
Check: How a process ends, and its status
A zombie is an exit status nobody has collected
POSIX defines a zombie process as the remains of a process “after it terminates” and
“before its status information … is consumed by its parent”. The kernel cannot drop the row when the process exits,
because the parent may still ask how it ended. The row costs no memory beyond itself, but it keeps the process ID, so
zombies that are never collected use up process IDs and process-table slots. This program creates one and looks at it
before and after waitpid:
#include <errno.h>
#include <signal.h>
#include <stdio.h>
#include <string.h>
#include <sys/wait.h>
#include <unistd.h>
#if defined(__APPLE__)
#include <sys/sysctl.h>
#endif
// The state letter ps would show for a process, or '-' when it has no row.
static char state_of(pid_t pid) {
#if defined(__APPLE__)
int name[] = {CTL_KERN, KERN_PROC, KERN_PROC_PID, (int)pid};
struct kinfo_proc info;
size_t size = sizeof info;
if (sysctl(name, 4, &info, &size, NULL, 0) != 0 || size == 0) {
return '-';
}
return info.kp_proc.p_stat == SZOMB ? 'Z' : 'S';
#else
char path[64];
snprintf(path, sizeof path, "/proc/%d/stat", (int)pid);
FILE *file = fopen(path, "r");
char state = '-';
if (file != NULL) {
int ignored = 0;
if (fscanf(file, "%d (%*[^)]) %c", &ignored, &state) != 2) {
state = '-';
}
fclose(file);
}
return state;
#endif
}
static void look(pid_t pid) {
const char state = state_of(pid);
if (state == '-') {
printf(" its row is gone from the process table\n");
} else {
printf(" ps would show it in state %c\n", state);
}
if (kill(pid, 0) == 0) {
printf(" kill(child, 0) succeeds: its process ID is still taken\n");
} else {
printf(" kill(child, 0) fails with %s\n",
errno == ESRCH ? "ESRCH, no such process" : strerror(errno));
}
}
int main(void) {
pid_t pid = fork();
if (pid < 0) {
perror("fork");
return 1;
}
if (pid == 0) {
_exit(7);
}
// Wait until the child has exited, but leave its status uncollected.
siginfo_t info;
memset(&info, 0, sizeof info);
waitid(P_PID, (id_t)pid, &info, WEXITED | WNOWAIT);
printf("the child has exited with status %d; nobody has collected it\n",
info.si_status);
look(pid);
int status = 0;
waitpid(pid, &status, 0);
printf("waitpid collected exit status %d\n", WEXITSTATUS(status));
look(pid);
return 0;
} Output
the child has exited with status 7; nobody has collected it ps would show it in state Z kill(child, 0) succeeds: its process ID is still taken waitpid collected exit status 7 its row is gone from the process table kill(child, 0) fails with ESRCH, no such process
Recorded with Apple clang version 21.0.0 (clang-2100.3.34.2) on macOS 26 arm64. To run it yourself: clang -std=c23 -O2 -Wall -Wextra -Werror -pedantic -fsanitize=address,undefined zombie.c -o zombie && ./zombie
waitid with WNOWAIT waited until the child had exited without collecting it, so for a moment the child was a pure
zombie. On macOS, the program read its state with sysctl (on Linux, from /proc/<pid>/stat): Z, the letter ps(1)
calls “defunct”. kill(child, 0) sends no signal and only checks whether the target exists, and it succeeded, because
POSIX counts a zombie as a process: its rationale says that returning ESRCH for one is “inappropriate”. Once waitpid
had collected status 7, the row was gone and the same call failed with ESRCH. Step through the row’s life:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- The child runs. The child has a process ID, memory, open files and a row in the kernel's process table, like any process.
- It exits with 7. The child calls _exit(7). The kernel frees its memory and closes its files, but keeps its row, with the PID and the status 7, because the parent has not asked for them yet.
- The parent is told. The kernel sends SIGCHLD to the parent. By default the parent ignores it, which, unlike setting SIGCHLD to SIG_IGN, leaves the child's row in place.
- A zombie. The child is now a zombie. ps would show it in state Z, and kill(child, 0) succeeds, because its process ID is still taken.
- The parent collects. The parent calls waitpid for the child and receives the status, 7. That was all the row was kept for.
- The row is gone. The kernel removes the row and frees the process ID. Now kill(child, 0) fails with ESRCH, no such process.
Key idea
A zombie is not a running process but a row the kernel keeps only so that the parent can read the exit status; collecting the status with wait, waitpid or waitid is what removes it.
Checkpoint
Check: Zombie processes
Collecting children with waitpid and SIGCHLD, or not at all
A parent that does other work cannot block in waitpid. It can instead collect children when they exit, in a handler
for SIGCHLD, which is called reaping: Collecting a terminated child's exit status with wait, waitpid or waitid. It removes the child's zombie entry from the process table and frees its process ID. them. The handler has one trap: standard signals do not
queue, signal(7) says, so five children that exit close together can arrive as fewer than five SIGCHLDs. A handler that
calls waitpid once per signal leaves zombies behind; one that loops with WNOHANG, which returns at once when no
child is ready, collects every child that has exited. The program does that, then shows the other way out:
#include <errno.h>
#include <signal.h>
#include <stdatomic.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/wait.h>
#include <unistd.h>
enum { CHILDREN = 5 };
static atomic_int collected; // children the handler has collected
static atomic_int statuses[CHILDREN]; // their exit statuses
// One SIGCHLD can stand for several children that exited, so the handler
// collects every child that has exited, without ever blocking.
static void on_child_exit(int sig) {
(void)sig;
const int saved_errno = errno;
int status = 0;
while (waitpid(-1, &status, WNOHANG) > 0) {
const int at = atomic_fetch_add(&collected, 1);
if (at < CHILDREN) {
atomic_store(&statuses[at], WEXITSTATUS(status));
}
}
errno = saved_errno;
}
static int by_value(const void *a, const void *b) {
return *(const int *)a - *(const int *)b;
}
int main(void) {
struct sigaction action;
memset(&action, 0, sizeof action);
action.sa_handler = on_child_exit;
sigemptyset(&action.sa_mask);
action.sa_flags = SA_RESTART | SA_NOCLDSTOP;
sigaction(SIGCHLD, &action, NULL);
sigset_t chld, old;
sigemptyset(&chld);
sigaddset(&chld, SIGCHLD);
sigprocmask(SIG_BLOCK, &chld, &old); // no SIGCHLD while we check the count
for (int i = 1; i <= CHILDREN; i++) {
if (fork() == 0) {
_exit(10 * i);
}
}
while (atomic_load(&collected) < CHILDREN) {
sigsuspend(&old); // sleep until a SIGCHLD has been handled
}
sigprocmask(SIG_SETMASK, &old, NULL);
int sorted[CHILDREN];
for (int i = 0; i < CHILDREN; i++) {
sorted[i] = atomic_load(&statuses[i]);
}
qsort(sorted, CHILDREN, sizeof sorted[0], by_value);
printf("the SIGCHLD handler collected %d children, statuses",
atomic_load(&collected));
for (int i = 0; i < CHILDREN; i++) {
printf(" %d", sorted[i]);
}
printf("\n");
signal(SIGCHLD, SIG_IGN); // ignored: the kernel keeps no zombie at all
for (int i = 0; i < 3; i++) {
if (fork() == 0) {
_exit(1);
}
}
errno = 0;
const pid_t got = wait(NULL);
printf("with SIGCHLD ignored, wait returned %d with %s\n", (int)got,
errno == ECHILD ? "ECHILD: no child left to collect"
: "another error");
return 0;
} Output
the SIGCHLD handler collected 5 children, statuses 10 20 30 40 50 with SIGCHLD ignored, wait returned -1 with ECHILD: no child left to collect
Recorded with Apple clang version 21.0.0 (clang-2100.3.34.2) on macOS 26 arm64. To run it yourself: clang -std=c23 -O2 -Wall -Wextra -Werror -pedantic -fsanitize=address,undefined reaper.c -o reaper && ./reaper
The handler collected all five children, with statuses 10 to 50, whatever number of signals actually arrived. In the
second part, SIGCHLD was set to SIG_IGN, and the children never became zombies: POSIX says that with SIGCHLD ignored, or
with the SA_NOCLDWAIT flag, the lifetime of an exiting child “shall end immediately”. With nothing left to collect,
wait returned −1 with ECHILD. The price is that the parent learns nothing about how its children ended.
One waitpid per SIGCHLD
void on_child(int s) { waitpid(-1, NULL, 0); } collects one child per signal and blocks when there is none. When two
children exit before the handler runs, one SIGCHLD arrives for both, and one child stays a zombie. Loop instead:
while (waitpid(-1, &status, WNOHANG) > 0) { … }, and save and restore errno around it.
Checkpoint
Check: Collecting children
Orphans are adopted by PID 1, or by a subreaper
A child whose parent exits first is an orphan process: A process whose parent has exited while it is still running. The kernel gives it a new parent: PID 1, or on Linux the nearest ancestor that has made itself a subreaper., and the kernel gives it a new parent at once. POSIX says an exiting process’s children get “an implementation-defined system process” as their new parent, and its rationale adds that this process has historically been init, with process ID 1. In practice it still is PID 1: The first user-mode process, started by the kernel at the end of its own start-up: systemd on most Linux systems, launchd on macOS. It starts the services and adopts orphaned processes. More in “How a computer boots an operating system”: systemd on many Linux systems, and launchd, which Apple calls the root process, on macOS. The boot lesson’s program watched an orphan’s parent become process 1. The new parent waits for every child it adopts, so an orphan that exits does not stay a zombie for long. The model below plays a small process tree in 14 languages: a shell, 10, has started a server, 11, with two workers, 12 and 13:
Python 3.14 · main.py
from dataclasses import dataclass
@dataclass
class Row: # a process-table row; Z: exited, its status not collected yet
ppid: int
state: str = "S"
status: int = 0
def collect(table: dict[int, Row], parent: int, child: int) -> None:
print(f" {parent} collects {child}: status {table.pop(child).status}")
def exit_process(table: dict[int, Row], pid: int, status: int) -> None:
row = table[pid]
row.state, row.status = "Z", status
print(f"{pid} exits with {status}: a zombie until {row.ppid} waits")
for child in sorted(table):
if table[child].ppid == pid:
table[child].ppid = 1
print(f" {child} is adopted by 1")
for child in sorted(table): # PID 1 waits for every child it has
if table[child].ppid == 1 and table[child].state == "Z":
collect(table, 1, child)
def wait_for(table: dict[int, Row], parent: int, child: int) -> None:
print(f"{parent} waits for {child}")
if table[child].state == "Z":
collect(table, parent, child)
else:
print(f" {child} is still running: the wait blocks")
def ps(table: dict[int, Row]) -> None:
print(" PID PPID STAT")
for pid in sorted(table):
print(f" {pid:5d} {table[pid].ppid:5d} {table[pid].state}")
def main() -> None:
table = {1: Row(0), 10: Row(1), 11: Row(10), 12: Row(11), 13: Row(11)}
exit_process(table, 12, 0)
wait_for(table, 11, 12)
exit_process(table, 11, 2)
ps(table)
exit_process(table, 13, 1)
wait_for(table, 10, 11)
ps(table)
main() In Python: The table is a dict from PID to a dataclass: sorted(table) walks the PIDs in order, and table.pop removes a row and returns it in one call.
Recorded with CPython 3.14.8 on macOS 26 arm64. It runs in your browser with Pyodide 314.0.7 (CPython 3.14.2). To run it yourself: python3 main.py
JavaScript Node 24 · main.mjs
// The process table: PID -> row. State Z: exited, its status not collected yet.
const pids = (table) =>
Object.keys(table)
.map(Number)
.sort((a, b) => a - b);
const collect = (table, parent, child) => {
console.log(` ${parent} collects ${child}: status ${table[child].status}`);
delete table[child];
};
const exitProcess = (table, pid, status) => {
Object.assign(table[pid], { state: "Z", status });
console.log(`${pid} exits with ${status}: a zombie until ${table[pid].ppid} waits`);
for (const child of pids(table).filter((c) => table[c].ppid === pid)) {
table[child].ppid = 1;
console.log(` ${child} is adopted by 1`);
}
// PID 1 waits for every child it has.
for (const child of pids(table)) {
if (table[child].ppid === 1 && table[child].state === "Z") collect(table, 1, child);
}
};
const waitFor = (table, parent, child) => {
console.log(`${parent} waits for ${child}`);
if (table[child].state === "Z") collect(table, parent, child);
else console.log(` ${child} is still running: the wait blocks`);
};
const ps = (table) => {
console.log(" PID PPID STAT");
for (const pid of pids(table)) {
const { ppid, state } = table[pid];
console.log(` ${String(pid).padStart(5)} ${String(ppid).padStart(5)} ${state}`);
}
};
const row = (ppid) => ({ ppid, state: "S", status: 0 });
const table = { 1: row(0), 10: row(1), 11: row(10), 12: row(11), 13: row(11) };
exitProcess(table, 12, 0);
waitFor(table, 11, 12);
exitProcess(table, 11, 2);
ps(table);
exitProcess(table, 13, 1);
waitFor(table, 10, 11);
ps(table); In JavaScript: The table is an object keyed by PID. Object.keys returns the keys as strings, so pids() turns them into numbers and sorts them with (a, b) => a - b, because the default sort compares text.
Recorded with Node.js 24.21.0 LTS (V8 13.6) on macOS 26 arm64. It runs in your browser with QuickJS (quickjs-emscripten 0.32.0). To run it yourself: node main.mjs
Java 25 LTS · Main.java
import java.util.List;
import java.util.Locale;
import java.util.TreeMap;
public class Main {
/** A row of the process table. State Z: exited, its status not collected yet. */
static final class Row {
int ppid;
char state = 'S';
int status;
Row(int ppid) {
this.ppid = ppid;
}
}
static final TreeMap<Integer, Row> TABLE = new TreeMap<>(); // sorted by PID
static void collect(int parent, int child) {
System.out.println(
" " + parent + " collects " + child + ": status " + TABLE.remove(child).status);
}
static void exitProcess(int pid, int status) {
Row row = TABLE.get(pid);
row.state = 'Z';
row.status = status;
System.out.println(pid + " exits with " + status + ": a zombie until " + row.ppid + " waits");
for (var entry : TABLE.entrySet()) {
if (entry.getValue().ppid == pid) {
entry.getValue().ppid = 1;
System.out.println(" " + entry.getKey() + " is adopted by 1");
}
}
// PID 1 waits for every child it has.
for (int child : List.copyOf(TABLE.keySet())) {
if (TABLE.get(child).ppid == 1 && TABLE.get(child).state == 'Z') {
collect(1, child);
}
}
}
static void waitFor(int parent, int child) {
System.out.println(parent + " waits for " + child);
if (TABLE.get(child).state == 'Z') {
collect(parent, child);
} else {
System.out.println(" " + child + " is still running: the wait blocks");
}
}
static void ps() {
System.out.println(" PID PPID STAT");
TABLE.forEach((pid, row) -> System.out.printf(" %5d %5d %c%n", pid, row.ppid, row.state));
}
public static void main(String[] args) {
Locale.setDefault(Locale.ROOT);
int[][] parents = {{1, 0}, {10, 1}, {11, 10}, {12, 11}, {13, 11}};
for (int[] p : parents) {
TABLE.put(p[0], new Row(p[1]));
}
exitProcess(12, 0);
waitFor(11, 12);
exitProcess(11, 2);
ps();
exitProcess(13, 1);
waitFor(10, 11);
ps();
}
} In Java: A TreeMap keeps the rows sorted by PID. The loop that removes rows walks List.copyOf(TABLE.keySet()), a snapshot, because changing a map while iterating over it throws ConcurrentModificationException.
Recorded with OpenJDK 25.0.4.1 LTS (Eclipse Temurin) on macOS 26 arm64. It runs in your browser with a TeaVM 0.16.0 build in QuickJS (quickjs-emscripten 0.32.0). To run it yourself: java Main.java
C++23 · main.cpp
#include <map>
#include <print>
#include <vector>
// A row of the process table. State Z: exited, its status not collected yet.
struct Row {
int ppid;
char state = 'S';
int status = 0;
};
using Table = std::map<int, Row>; // sorted by PID
void collect(Table &table, int parent, int child) {
std::println(" {} collects {}: status {}", parent, child,
table.at(child).status);
table.erase(child);
}
void exit_process(Table &table, int pid, int status) {
Row &row = table.at(pid);
row.state = 'Z';
row.status = status;
std::println("{} exits with {}: a zombie until {} waits", pid, status,
row.ppid);
for (auto &[child, r] : table) {
if (r.ppid == pid) {
r.ppid = 1;
std::println(" {} is adopted by 1", child);
}
}
std::vector<int> done; // PID 1 waits for every child it has
for (const auto &[child, r] : table)
if (r.ppid == 1 && r.state == 'Z')
done.push_back(child);
for (int child : done)
collect(table, 1, child);
}
void wait_for(Table &table, int parent, int child) {
std::println("{} waits for {}", parent, child);
if (table.at(child).state == 'Z')
collect(table, parent, child);
else
std::println(" {} is still running: the wait blocks", child);
}
void ps(const Table &table) {
std::println(" PID PPID STAT");
for (const auto &[pid, r] : table)
std::println(" {:5} {:5} {}", pid, r.ppid, r.state);
}
int main() {
Table table = {{1, {0}}, {10, {1}}, {11, {10}}, {12, {11}}, {13, {11}}};
exit_process(table, 12, 0);
wait_for(table, 11, 12);
exit_process(table, 11, 2);
ps(table);
exit_process(table, 13, 1);
wait_for(table, 10, 11);
ps(table);
} In C++: std::map keeps the rows sorted by PID, and structured bindings name each key and row. The zombies PID 1 collects are gathered in a vector first, because erasing an element invalidates the iterator that points to it.
Recorded with Apple clang 21.0.0 (C++23) and GCC 16.2.0 on macOS 26 arm64. It runs in your browser with Clang in WebAssembly (YoWASP) 22.0.0-git20542-10. To run it yourself: clang++ -std=c++23 -O2 -Wall -Wextra -Werror -pedantic -fsanitize=address,undefined main.cpp -o main && ./main
C# 14 · .NET 10 · app.cs
var table = new SortedDictionary<int, Row>(); // sorted by PID
foreach (var (pid, ppid) in new[] { (1, 0), (10, 1), (11, 10), (12, 11), (13, 11) })
{
table[pid] = new Row(ppid);
}
ExitProcess(table, 12, 0);
WaitFor(table, 11, 12);
ExitProcess(table, 11, 2);
Ps(table);
ExitProcess(table, 13, 1);
WaitFor(table, 10, 11);
Ps(table);
static void Collect(SortedDictionary<int, Row> table, int parent, int child)
{
Console.WriteLine($" {parent} collects {child}: status {table[child].Status}");
table.Remove(child);
}
static void ExitProcess(SortedDictionary<int, Row> table, int pid, int status)
{
var row = table[pid];
(row.State, row.Status) = ('Z', status);
Console.WriteLine($"{pid} exits with {status}: a zombie until {row.Ppid} waits");
foreach (var (child, r) in table.Where(e => e.Value.Ppid == pid))
{
r.Ppid = 1;
Console.WriteLine($" {child} is adopted by 1");
}
// PID 1 waits for every child it has.
var done = table.Keys.Where(c => table[c] is { Ppid: 1, State: 'Z' }).ToList();
done.ForEach(c => Collect(table, 1, c));
}
static void WaitFor(SortedDictionary<int, Row> table, int parent, int child)
{
Console.WriteLine($"{parent} waits for {child}");
if (table[child].State == 'Z')
{
Collect(table, parent, child);
}
else
{
Console.WriteLine($" {child} is still running: the wait blocks");
}
}
static void Ps(SortedDictionary<int, Row> table)
{
Console.WriteLine(" PID PPID STAT");
foreach (var (pid, r) in table)
{
Console.WriteLine($" {pid,5} {r.Ppid,5} {r.State}");
}
}
// A row of the process table. State Z: exited, its status not collected yet.
sealed class Row(int ppid)
{
public int Ppid { get; set; } = ppid;
public char State { get; set; } = 'S';
public int Status { get; set; }
} In C#: A SortedDictionary keeps the rows in PID order, and the property pattern { Ppid: 1, State: 'Z' } picks out the zombies PID 1 has adopted; ToList copies them first, because a dictionary must not change while it is enumerated.
Recorded with C# 14 on .NET 10.0.12 (SDK 10.0.401) on macOS 26 arm64. It runs in your browser with the .NET runtime for WebAssembly 10.0.12. To run it yourself: dotnet run app.cs
C23 · main.c
#include <stdio.h>
// A row of the process table. State Z: exited, its status not collected yet.
typedef struct {
int pid, ppid;
char state;
int status;
} Row;
static Row table[8] = {{1, 0, 'S', 0},
{10, 1, 'S', 0},
{11, 10, 'S', 0},
{12, 11, 'S', 0},
{13, 11, 'S', 0}};
static int count = 5; // rows in use, sorted by PID
static int find(int pid) {
int at = 0;
while (table[at].pid != pid)
at++;
return at;
}
static void collect(int parent, int at) {
printf(" %d collects %d: status %d\n", parent, table[at].pid,
table[at].status);
for (int i = at; i + 1 < count; i++)
table[i] = table[i + 1];
count--;
}
static void exit_process(int pid, int status) {
Row *row = &table[find(pid)];
row->state = 'Z';
row->status = status;
printf("%d exits with %d: a zombie until %d waits\n", pid, status, row->ppid);
for (int i = 0; i < count; i++) {
if (table[i].ppid == pid) {
table[i].ppid = 1;
printf(" %d is adopted by 1\n", table[i].pid);
}
}
for (int i = 0; i < count;) // PID 1 waits for every child it has
if (table[i].ppid == 1 && table[i].state == 'Z')
collect(1, i);
else
i++;
}
static void wait_for(int parent, int child) {
printf("%d waits for %d\n", parent, child);
if (table[find(child)].state == 'Z')
collect(parent, find(child));
else
printf(" %d is still running: the wait blocks\n", child);
}
static void ps(void) {
printf(" PID PPID STAT\n");
for (int i = 0; i < count; i++)
printf(" %5d %5d %c\n", table[i].pid, table[i].ppid, table[i].state);
}
int main(void) {
exit_process(12, 0);
wait_for(11, 12);
exit_process(11, 2);
ps();
exit_process(13, 1);
wait_for(10, 11);
ps();
return 0;
} In C: The table is a fixed array of rows kept in PID order. Collecting a row moves the later rows up by one, which is why the loop that collects zombies advances i only past a row it keeps.
Recorded with Apple clang 21.0.0 (C23) and GCC 16.2.0 on macOS 26 arm64. It runs in your browser with Clang in WebAssembly (YoWASP) 22.0.0-git20542-10. To run it yourself: clang -std=c23 -O2 -Wall -Wextra -Werror -pedantic -fsanitize=address,undefined main.c -o main && ./main
PHP 8.5 · main.php
<?php
declare(strict_types=1);
// A row of the process table. State Z: exited, its status not collected yet.
final class Row
{
public string $state = 'S';
public int $status = 0;
public function __construct(public int $ppid) {}
}
/** @param array<int, Row> $table */
function collect(array &$table, int $parent, int $child): void
{
echo " {$parent} collects {$child}: status {$table[$child]->status}\n";
unset($table[$child]);
}
/** @param array<int, Row> $table */
function exitProcess(array &$table, int $pid, int $status): void
{
[$table[$pid]->state, $table[$pid]->status] = ['Z', $status];
echo "{$pid} exits with {$status}: a zombie until {$table[$pid]->ppid} waits\n";
foreach ($table as $child => $row) {
if ($row->ppid === $pid) {
$row->ppid = 1;
echo " {$child} is adopted by 1\n";
}
}
foreach (array_keys($table) as $child) { // PID 1 waits for every child it has
if ($table[$child]->ppid === 1 && $table[$child]->state === 'Z') {
collect($table, 1, $child);
}
}
}
/** @param array<int, Row> $table */
function waitFor(array &$table, int $parent, int $child): void
{
echo "{$parent} waits for {$child}\n";
if ($table[$child]->state === 'Z') {
collect($table, $parent, $child);
} else {
echo " {$child} is still running: the wait blocks\n";
}
}
/** @param array<int, Row> $table */
function ps(array $table): void
{
echo " PID PPID STAT\n";
foreach ($table as $pid => $row) {
printf(" %5d %5d %s\n", $pid, $row->ppid, $row->state);
}
}
// PID => parent's PID, in PID order, which a PHP array keeps.
$parents = [1 => 0, 10 => 1, 11 => 10, 12 => 11, 13 => 11];
$table = array_map(fn(int $ppid) => new Row($ppid), $parents);
exitProcess($table, 12, 0);
waitFor($table, 11, 12);
exitProcess($table, 11, 2);
ps($table);
exitProcess($table, 13, 1);
waitFor($table, 10, 11);
ps($table); In PHP: A PHP array keeps its keys in insertion order, and the rows go in by PID, so no walk needs a sort. array_map keeps the keys of the one array it maps, which turns the PID => parent list into the table.
Recorded with PHP 8.5.11 (CLI) on macOS 26 arm64. It runs in your browser with php-wasm 0.2.0 (PHP 8.5.2). To run it yourself: php main.php
Rust 1.99 · main.rs
use std::collections::BTreeMap;
/// A row of the process table: a zombie has exited, and its status waits in
/// `exited` until its parent collects it.
struct Row {
ppid: u32,
exited: Option<i32>,
}
type Table = BTreeMap<u32, Row>; // sorted by PID
fn collect(table: &mut Table, parent: u32, child: u32) {
if let Some(status) = table.remove(&child).and_then(|row| row.exited) {
println!(" {parent} collects {child}: status {status}");
}
}
fn exit_process(table: &mut Table, pid: u32, status: i32) {
let Some(row) = table.get_mut(&pid) else {
return;
};
row.exited = Some(status);
let ppid = row.ppid;
println!("{pid} exits with {status}: a zombie until {ppid} waits");
for (child, row) in table.iter_mut().filter(|(_, row)| row.ppid == pid) {
row.ppid = 1;
println!(" {child} is adopted by 1");
}
table.retain(|child, row| match (row.ppid, row.exited) {
(1, Some(status)) => {
println!(" 1 collects {child}: status {status}"); // PID 1 waits for every child
false
}
_ => true,
});
}
fn wait_for(table: &mut Table, parent: u32, child: u32) {
println!("{parent} waits for {child}");
match table.get(&child).and_then(|row| row.exited) {
Some(_) => collect(table, parent, child),
None => println!(" {child} is still running: the wait blocks"),
}
}
fn ps(table: &Table) {
println!(" PID PPID STAT");
for (pid, row) in table {
let state = if row.exited.is_some() { 'Z' } else { 'S' };
println!(" {pid:5} {:5} {state}", row.ppid);
}
}
fn main() {
let parents = [(1, 0), (10, 1), (11, 10), (12, 11), (13, 11)];
let mut table: Table = parents
.map(|(pid, ppid)| (pid, Row { ppid, exited: None }))
.into();
exit_process(&mut table, 12, 0);
wait_for(&mut table, 11, 12);
exit_process(&mut table, 11, 2);
ps(&table);
exit_process(&mut table, 13, 1);
wait_for(&mut table, 10, 11);
ps(&table);
} In Rust: A row's exit status is an Option<i32>, so a zombie is a row whose status is Some. A BTreeMap keeps the rows in PID order, and retain walks them and drops the zombies PID 1 collects.
Recorded with rustc 1.99.0 (edition 2024) on macOS 26 arm64. It runs in your browser with a WebAssembly build on the WASI runner (browser_wasi_shim 0.4.2). To run it yourself: rustc --edition 2024 -O main.rs && ./main
TypeScript 6.0 · main.ts
// A row of the process table. State Z: exited, its status not collected yet.
interface Row {
ppid: number;
state: "S" | "Z";
status: number;
}
type Table = Record<number, Row>;
const pids = (table: Table): number[] =>
Object.keys(table)
.map(Number)
.sort((a, b) => a - b);
const collect = (table: Table, parent: number, child: number): void => {
console.log(` ${parent} collects ${child}: status ${table[child].status}`);
delete table[child];
};
const exitProcess = (table: Table, pid: number, status: number): void => {
table[pid].state = "Z";
table[pid].status = status;
console.log(`${pid} exits with ${status}: a zombie until ${table[pid].ppid} waits`);
for (const child of pids(table).filter((c) => table[c].ppid === pid)) {
table[child].ppid = 1;
console.log(` ${child} is adopted by 1`);
}
// PID 1 waits for every child it has.
for (const child of pids(table)) {
if (table[child].ppid === 1 && table[child].state === "Z") collect(table, 1, child);
}
};
const waitFor = (table: Table, parent: number, child: number): void => {
console.log(`${parent} waits for ${child}`);
if (table[child].state === "Z") collect(table, parent, child);
else console.log(` ${child} is still running: the wait blocks`);
};
const ps = (table: Table): void => {
console.log(" PID PPID STAT");
for (const pid of pids(table)) {
const { ppid, state } = table[pid];
console.log(` ${String(pid).padStart(5)} ${String(ppid).padStart(5)} ${state}`);
}
};
const row = (ppid: number): Row => ({ ppid, state: "S", status: 0 });
const table: Table = { 1: row(0), 10: row(1), 11: row(10), 12: row(11), 13: row(11) };
exitProcess(table, 12, 0);
waitFor(table, 11, 12);
exitProcess(table, 11, 2);
ps(table);
exitProcess(table, 13, 1);
waitFor(table, 10, 11);
ps(table); In TypeScript: Row's state has the type "S" | "Z", so tsc --strict rejects any other letter, and Record<number, Row> types the table; the rest is the JavaScript version.
Recorded with TypeScript 6.0.3 (strict), run by Node.js 24.21.0 on macOS 26 arm64. It runs in your browser with QuickJS (quickjs-emscripten 0.32.0) after Sucrase 3.35.1. To run it yourself: npx -p typescript@6.0.3 tsc --noEmit --strict main.ts && node main.ts
Go 1.27 · main.go
package main
import (
"fmt"
"maps"
"slices"
)
// row is a row of the process table. State Z: exited, its status not
// collected yet.
type row struct{ ppid, state, status int }
func collect(table map[int]*row, parent, child int) {
fmt.Printf(" %d collects %d: status %d\n", parent, child, table[child].status)
delete(table, child)
}
func exitProcess(table map[int]*row, pid, status int) {
r := table[pid]
r.state, r.status = 'Z', status
fmt.Printf("%d exits with %d: a zombie until %d waits\n", pid, status, r.ppid)
for _, child := range slices.Sorted(maps.Keys(table)) {
if table[child].ppid == pid {
table[child].ppid = 1
fmt.Printf(" %d is adopted by 1\n", child)
}
}
for _, child := range slices.Sorted(maps.Keys(table)) { // PID 1 waits for every child
if c := table[child]; c.ppid == 1 && c.state == 'Z' {
collect(table, 1, child)
}
}
}
func waitFor(table map[int]*row, parent, child int) {
fmt.Printf("%d waits for %d\n", parent, child)
if table[child].state == 'Z' {
collect(table, parent, child)
} else {
fmt.Printf(" %d is still running: the wait blocks\n", child)
}
}
func ps(table map[int]*row) {
fmt.Println(" PID PPID STAT")
for _, pid := range slices.Sorted(maps.Keys(table)) {
fmt.Printf(" %5d %5d %c\n", pid, table[pid].ppid, table[pid].state)
}
}
func main() {
table := map[int]*row{}
for pid, ppid := range map[int]int{1: 0, 10: 1, 11: 10, 12: 11, 13: 11} {
table[pid] = &row{ppid: ppid, state: 'S'}
}
exitProcess(table, 12, 0)
waitFor(table, 11, 12)
exitProcess(table, 11, 2)
ps(table)
exitProcess(table, 13, 1)
waitFor(table, 10, 11)
ps(table)
} In Go: Go randomises the order in which a map is walked, so every walk ranges over slices.Sorted(maps.Keys(table)). The map holds pointers, so a change to a row needs no write back into the map.
Recorded with Go 1.27.1 on macOS 26 arm64. It runs in your browser with the Go compiler in WebAssembly 1.27.1. To run it yourself: go run main.go
Swift 6.4 · main.swift
// A row of the process table. State Z: exited, its status not collected yet.
struct Row {
var ppid: Int
var state: Character = "S"
var status = 0
}
func padded(_ number: Int) -> String {
let text = String(number)
return String(repeating: " ", count: max(0, 5 - text.count)) + text
}
func collect(_ table: inout [Int: Row], parent: Int, child: Int) {
if let row = table.removeValue(forKey: child) {
print(" \(parent) collects \(child): status \(row.status)")
}
}
func exitProcess(_ table: inout [Int: Row], pid: Int, status: Int) {
table[pid]?.state = "Z"
table[pid]?.status = status
print("\(pid) exits with \(status): a zombie until \(table[pid]?.ppid ?? 0) waits")
for child in table.keys.sorted() where table[child]?.ppid == pid {
table[child]?.ppid = 1
print(" \(child) is adopted by 1")
}
for child in table.keys.sorted() where table[child]?.ppid == 1 && table[child]?.state == "Z" {
collect(&table, parent: 1, child: child) // PID 1 waits for every child it has
}
}
func waitFor(_ table: inout [Int: Row], parent: Int, child: Int) {
print("\(parent) waits for \(child)")
if table[child]?.state == "Z" {
collect(&table, parent: parent, child: child)
} else {
print(" \(child) is still running: the wait blocks")
}
}
func ps(_ table: [Int: Row]) {
print(" PID PPID STAT")
for (pid, row) in table.sorted(by: { $0.key < $1.key }) {
print(" \(padded(pid)) \(padded(row.ppid)) \(row.state)")
}
}
var table: [Int: Row] = [
1: Row(ppid: 0), 10: Row(ppid: 1), 11: Row(ppid: 10), 12: Row(ppid: 11), 13: Row(ppid: 11),
]
exitProcess(&table, pid: 12, status: 0)
waitFor(&table, parent: 11, child: 12)
exitProcess(&table, pid: 11, status: 2)
ps(table)
exitProcess(&table, pid: 13, status: 1)
waitFor(&table, parent: 10, child: 11)
ps(table) In Swift: A dictionary has no order, so each walk sorts its keys first, and where clauses filter the loops. padded() right-aligns a number without importing Foundation.
Recorded with Apple Swift 6.4 on macOS 26 arm64. It runs in your browser with a WebAssembly build on the WASI runner (browser_wasi_shim 0.4.2). To run it yourself: swiftc -O main.swift -o main && ./main
Ruby 4.0 · main.rb
# frozen_string_literal: true
# A row of the process table. State Z: exited, its status not collected yet.
Row = Struct.new(:ppid, :state, :status)
def collect(table, parent, child)
puts " #{parent} collects #{child}: status #{table.delete(child).status}"
end
def adopt_children(table, pid)
table.each do |child, row|
next unless row.ppid == pid
row.ppid = 1
puts " #{child} is adopted by 1"
end
end
def exit_process(table, pid, status)
row = table[pid]
row.state = 'Z'
row.status = status
puts "#{pid} exits with #{status}: a zombie until #{row.ppid} waits"
adopt_children(table, pid)
zombies = table.select { |_, child_row| child_row.ppid == 1 && child_row.state == 'Z' }
zombies.each_key { |child| collect(table, 1, child) } # PID 1 waits for every child it has
end
def wait_for(table, parent, child)
puts "#{parent} waits for #{child}"
if table[child].state == 'Z'
collect(table, parent, child)
else
puts " #{child} is still running: the wait blocks"
end
end
def ps(table)
puts ' PID PPID STAT'
table.each do |pid, row|
puts format(' %<pid>5d %<ppid>5d %<state>s', pid: pid, ppid: row.ppid, state: row.state)
end
end
parents = { 1 => 0, 10 => 1, 11 => 10, 12 => 11, 13 => 11 }
table = parents.transform_values { |ppid| Row.new(ppid, 'S', 0) }
exit_process(table, 12, 0)
wait_for(table, 11, 12)
exit_process(table, 11, 2)
ps(table)
exit_process(table, 13, 1)
wait_for(table, 10, 11)
ps(table) In Ruby: A Hash keeps its keys in the order they were added, here PID order, so no walk needs a sort. Hash#select returns a new hash, so collect can delete rows from the table while the zombies are walked.
Recorded with CRuby 4.0.7 on macOS 26 arm64. It runs in your browser with ruby.wasm 2.10.1 (Ruby 4.0.0). To run it yourself: ruby main.rb
Kotlin 2.4 · Main.kt
// A row of the process table. State Z: exited, its status not collected yet.
class Row(
var ppid: Int,
) {
var state = 'S'
var status = 0
}
val table = mutableMapOf(1 to Row(0), 10 to Row(1), 11 to Row(10), 12 to Row(11), 13 to Row(11))
fun collect(
parent: Int,
child: Int,
) {
println(" $parent collects $child: status ${table.getValue(child).status}")
table.remove(child)
}
fun exitProcess(
pid: Int,
status: Int,
) {
val row = table.getValue(pid)
row.state = 'Z'
row.status = status
println("$pid exits with $status: a zombie until ${row.ppid} waits")
for (child in table.keys.sorted().filter { table.getValue(it).ppid == pid }) {
table.getValue(child).ppid = 1
println(" $child is adopted by 1")
}
// PID 1 waits for every child it has.
table.keys
.sorted()
.filter { table.getValue(it).ppid == 1 && table.getValue(it).state == 'Z' }
.forEach { collect(1, it) }
}
fun waitFor(
parent: Int,
child: Int,
) {
println("$parent waits for $child")
if (table.getValue(child).state == 'Z') {
collect(parent, child)
} else {
println(" $child is still running: the wait blocks")
}
}
fun ps() {
println(" PID PPID STAT")
for (pid in table.keys.sorted()) {
val row = table.getValue(pid)
println(" ${pid.toString().padStart(5)} ${row.ppid.toString().padStart(5)} ${row.state}")
}
}
fun main() {
exitProcess(12, 0)
waitFor(11, 12)
exitProcess(11, 2)
ps()
exitProcess(13, 1)
waitFor(10, 11)
ps()
} In Kotlin: Every walk sorts the keys first, so the order never depends on how the map was filled, and getValue throws for a missing PID instead of returning null.
Recorded with Kotlin 2.4.20 on Java 25.0.4.1 on macOS 26 arm64. It runs in your browser with a WebAssembly build on the WASI runner (browser_wasi_shim 0.4.2). To run it yourself: kotlinc Main.kt -include-runtime -d main.jar && java -jar main.jar
Dart 3.13 · main.dart
import 'dart:collection';
// A row of the process table. State Z: exited, its status not collected yet.
class Row {
Row(this.ppid);
int ppid;
String state = 'S';
int status = 0;
}
final table = SplayTreeMap<int, Row>.of({
1: Row(0),
10: Row(1),
11: Row(10),
12: Row(11),
13: Row(11),
}); // sorted by PID
void collect(int parent, int child) {
print(' $parent collects $child: status ${table.remove(child)!.status}');
}
void exitProcess(int pid, int status) {
final row = table[pid]!
..state = 'Z'
..status = status;
print('$pid exits with $status: a zombie until ${row.ppid} waits');
for (final MapEntry(:key, :value) in table.entries) {
if (value.ppid == pid) {
value.ppid = 1;
print(' $key is adopted by 1');
}
}
final done = [
for (final MapEntry(:key, :value) in table.entries)
if (value.ppid == 1 && value.state == 'Z') key,
];
for (final child in done) {
collect(1, child); // PID 1 waits for every child it has
}
}
void waitFor(int parent, int child) {
print('$parent waits for $child');
if (table[child]!.state == 'Z') {
collect(parent, child);
} else {
print(' $child is still running: the wait blocks');
}
}
void ps() {
print(' PID PPID STAT');
table.forEach((pid, row) {
print(' ${'$pid'.padLeft(5)} ${'${row.ppid}'.padLeft(5)} ${row.state}');
});
}
void main() {
exitProcess(12, 0);
waitFor(11, 12);
exitProcess(11, 2);
ps();
exitProcess(13, 1);
waitFor(10, 11);
ps();
} In Dart: A SplayTreeMap from dart:collection keeps the rows sorted by PID, a cascade sets a row's state and status in one statement, and a collection for with an if gathers the zombies before any is removed.
Recorded with Dart 3.13.5 on macOS 26 arm64. It runs in your browser with a WebAssembly build by dart2wasm 3.13.5. To run it yourself: dart run main.dart
What every language printed
12 exits with 0: a zombie until 11 waits
11 waits for 12
11 collects 12: status 0
11 exits with 2: a zombie until 10 waits
13 is adopted by 1
PID PPID STAT
1 0 S
10 1 S
11 10 Z
13 1 S
13 exits with 1: a zombie until 1 waits
1 collects 13: status 1
10 waits for 11
10 collects 11: status 2
PID PPID STAT
1 0 S
10 1 S
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs. Runs on this device, in your browser. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs. Runs on this device: a JavaScript build of this program (about 68 KB), made when the page was built. The first run also downloads the JavaScript runner (about 0.6 MB), which is kept for the next runs. To change the code, copy it and run it with the command below. Runs on this device: a WebAssembly build of this program (about 153 KB), made when the page was built. Edit runs your changes in the browser: the first edited run downloads the C and C++ compiler (about 29.1 MB), which is kept for the next runs. It is best on a computer. Runs on this device, in your browser. The first run downloads the C and C++ compiler (about 29.1 MB), which is kept for the next runs. It is best on a computer. Runs on this device: a .NET build of this program (about 4 KB), made when the page was built. The first run also downloads .NET (about 3.6 MB), which is kept for the next runs. Edit runs your changes in the browser: the first edited run downloads .NET (about 8.9 MB), which is kept for the next runs. Runs on this device, in your browser. The first run downloads .NET (about 8.9 MB), which is kept for the next runs. Runs on this device: a WebAssembly build of this program (about 9 KB), made when the page was built. Edit runs your changes in the browser: the first edited run downloads the C and C++ compiler (about 29.1 MB), which is kept for the next runs. It is best on a computer. Runs on this device, in your browser. The first run downloads the C and C++ compiler (about 29.1 MB), which is kept for the next runs. It is best on a computer. Runs on this device, in your browser. The first run downloads PHP (about 4.3 MB), which is kept for the next runs. Runs on this device: a WebAssembly build of this program (about 24 KB), made when the page was built. To change the code, copy it and run it with the command below. Runs on this device, in your browser. The first run downloads the JavaScript runner (about 0.9 MB), which is kept for the next runs. Runs on this device: a WebAssembly build of this program (about 727 KB), made when the page was built. Edit runs your changes in the browser: the first edited run downloads the Go compiler (about 18.7 MB), which is kept for the next runs. Runs on this device, in your browser. The first run downloads the Go compiler (about 18.7 MB), which is kept for the next runs. Runs on this device: a WebAssembly build of this program (about 1.7 MB), made when the page was built. To change the code, copy it and run it with the command below. Runs on this device, in your browser. The first run downloads Ruby (about 10.2 MB), which is kept for the next runs. Runs on this device: a WebAssembly build of this program (about 194 KB), made when the page was built. To change the code, copy it and run it with the command below. Runs on this device: a WebAssembly build of this program (about 18 KB), made when the page was built. To change the code, copy it and run it with the command below.Your run, in this browser
Follow the rows. 12 stayed a zombie only until 11 waited for it. When 11 exited, its worker 13 was still running and
was adopted by 1 at once, so the first ps shows 11 as a zombie waiting for 10, and 13 with its new parent, 1. When 13
exited, 1 collected its status in the same step, so no ps ever listed 13 as a zombie, and 10’s wait then collected
11’s status, 2, which left only 1 and 10 in the table.
Linux lets a process take PID 1’s place for its own descendants. A process that sets the
subreaper: On Linux, a process that has called prctl with PR_SET_CHILD_SUBREAPER. Orphans below it in the process tree are given to it as their parent instead of to PID 1. attribute with prctl(PR_SET_CHILD_SUBREAPER) “fulfills the role of init(1)
for its descendant processes”, in the words of its manual page: an orphan below it is “reparented to the nearest still
living ancestor subreaper”. Service managers use it to keep track of a service’s processes even when they double-fork.
A container’s first process has the same duty for everything inside the container: pid_namespaces(7) makes the first
process of a new PID namespace the namespace’s “init”, the parent of the orphans inside it.
Checkpoint
Check: Orphans and their new parent
Process groups, sessions and daemons
A shell runs a pipeline such as yes | grep y | wc -l, which never ends by itself, as one job, and Ctrl-C must end all
three commands and not the shell. process group: A set of processes that can be signalled together, such as the commands of one shell pipeline. kill with a negative process ID signals every process of a group. make that possible: POSIX defines one
as “a collection of processes that permits the signaling of related processes”, and kill with a negative process ID
sends a signal to every member of the group. A session: A set of process groups, usually all those started from one terminal. One group at a time is in the foreground and gets the signals the terminal sends for keys such as Ctrl-C. is a collection of process groups,
usually all those started from one terminal; the terminal belongs to one session, and the keys that make signals, such
as Ctrl-C, signal every process in its foreground group. What each process then does is its own choice: these three
take SIGINT’s default action and end, while less, for example, catches it and returns to its prompt, as its manual
page says. Step through the pipeline and the key press that ends it:
The drawing shows the whole scene; its 5 steps are listed below.
Every step as text
- One group for the job. The shell forks three children for yes, grep and wc, and puts all three in a new process group with setpgid. The pipeline would run for ever.
- The foreground. The shell makes that group the terminal's foreground process group, so the job may read from the terminal and receives its signals.
- Ctrl-C. The user presses Ctrl-C. The terminal driver turns the key into SIGINT for every process of the foreground group, not for one process.
- The whole job stops. SIGINT reaches yes, grep and wc at once. None of them catches it, so its default action ends all three, and the whole pipeline is gone.
- The shell is untouched. The shell is in a different process group, so it got no SIGINT. It collects the three children with waitpid and takes the foreground back.
The program below does the same with three children in a group of their own and one kill, then starts a new session
the way a daemon does:
#include <errno.h>
#include <signal.h>
#include <stdio.h>
#include <sys/wait.h>
#include <unistd.h>
// Three children in a process group of their own, as a shell puts the
// commands of one pipeline, then one signal to the whole group.
static void one_signal_for_a_group(void) {
pid_t group = 0;
pid_t children[3];
for (int i = 0; i < 3; i++) {
pid_t pid = fork();
if (pid == 0) {
setpgid(0, group); // the first child leads a new group
pause(); // wait for a signal
_exit(0);
}
if (i == 0) {
group = pid;
}
setpgid(pid, group); // the parent sets it too, so there is no race
children[i] = pid;
}
printf("the 3 children share a group that the parent is not in: %s\n",
getpgid(children[2]) == group && getpgrp() != group ? "yes" : "no");
kill(-group, SIGTERM); // a negative PID sends to the whole group
for (int i = 0; i < 3; i++) {
int status = 0;
waitpid(children[i], &status, 0);
printf(" child %d: killed by signal %d\n", i + 1,
WIFSIGNALED(status) ? WTERMSIG(status) : 0);
}
printf("the parent, outside the group, is still running\n");
}
// A child that starts a session of its own, as a daemon does.
static void a_new_session(void) {
fflush(stdout);
pid_t pid = fork();
if (pid == 0) {
const pid_t before = getsid(0);
const pid_t session = setsid();
printf("the child was in its parent's session: %s\n",
before == getsid(getppid()) ? "yes" : "no");
printf("after setsid it leads a new session and a new group: %s\n",
session == getpid() && getpgrp() == getpid() ? "yes" : "no");
errno = 0;
printf("a second setsid fails with EPERM: %s\n",
setsid() == -1 && errno == EPERM ? "yes" : "no");
fflush(stdout); // _exit would throw the buffered lines away
_exit(0);
}
waitpid(pid, NULL, 0);
}
int main(void) {
one_signal_for_a_group();
a_new_session();
return 0;
} Output
the 3 children share a group that the parent is not in: yes child 1: killed by signal 15 child 2: killed by signal 15 child 3: killed by signal 15 the parent, outside the group, is still running the child was in its parent's session: yes after setsid it leads a new session and a new group: yes a second setsid fails with EPERM: yes
Recorded with Apple clang version 21.0.0 (clang-2100.3.34.2) on macOS 26 arm64. To run it yourself: clang -std=c23 -O2 -Wall -Wextra -Werror -pedantic -fsanitize=address,undefined process_group.c -o process_group && ./process_group
All three children were killed by signal 15 from a single kill(-group, SIGTERM), while the parent, outside the group,
kept running. In the second part, a new child called setsid, which made it the leader of a new session and of a new
process group, with no controlling terminal; a second setsid failed with EPERM, because a process group leader may not start a
session, as setsid(2) says. That is why the classic daemon: A program that runs in the background without a controlling terminal, usually as a service. Classically it detaches with fork and setsid; today a service manager such as systemd or launchd starts it. recipe in daemon(7) forks first,
calls setsid in the child, which can never be a group leader, and forks again, so that the daemon can never acquire a
terminal. Without a terminal it no longer receives the SIGHUP that POSIX sends to the foreground group when the
terminal’s controlling process exits. A service manager such as systemd starts each service in its own session to
begin with, and daemon(7) recommends that a program run that way skips the classic steps.
Checkpoint
Check: Process groups, sessions and daemons
Practise: decode a status word
Write describe, which turns the status word a parent receives into words, exited, killed or stopped, using the
layout the lesson’s recorded run showed. The sample tests run in your browser and include the six statuses of that run:
Exercise · Easy · Python
Decode the status word waitpid returns
waitpid fills in a single integer, the status word, and the W* macros take it apart. POSIX defines only the macros, but Linux's C library and macOS lay the word out in the same way, as the lesson's recorded run showed with 0x0300 for exit(3):
- the low 7 bits (
status & 0x7f) hold the number of the signal that ended the child; they are 0 if the child exited, and0x7fif it is stopped; - bit 7 (
0x80) is set when the child was killed and wrote a core dump; - bits 8 to 15 (
(status >> 8) & 0xff) hold the exit status, or, for a stopped child, the signal that stopped it.
Write describe(status) in status.py, which returns one of these strings: "exited with 3", "killed by signal 15", "killed by signal 11, core dumped" or "stopped by signal 17", with the numbers taken from the word. The special value a child that continued gets is different on Linux and macOS and is left out.
Starter code · status.py
def describe(status):
"""What a wait status word says about how the child ended, or why it stopped."""
# Replace this line with your code.
return "" The sample tests · test_status.py
from status import describe
def test_the_recorded_exits():
"""reads the exit statuses of the lesson's recorded run"""
assert describe(0x0000) == "exited with 0"
assert describe(0x0300) == "exited with 3"
assert describe(0xFF00) == "exited with 255"
def test_the_recorded_signals():
"""reads the signals of the lesson's recorded run"""
assert describe(0x000F) == "killed by signal 15"
assert describe(0x0009) == "killed by signal 9"
def test_a_core_dump():
"""reports the core dump flag of a child killed by signal 11"""
assert describe(0x008B) == "killed by signal 11, core dumped"
def test_a_stopped_child():
"""reads the stop signal from bits 8 to 15"""
assert describe(0x117F) == "stopped by signal 17" A hint
Look at the low 7 bits first, status & 0x7f: 0 means the child exited, 0x7f means it is stopped, and anything else is the number of the signal that killed it. Only then read the other bits.
Results of the sample tests
| Test | Result | Details |
|---|
What your code printed
The sample tests run on this device, in your browser (Pyodide): nothing is sent to mysmartcopilot.com. The first run downloads Python (about 13.5 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.
Last, the questions below go back over every section of the lesson, and they open with the idea that your earlier answers show needs the most work:
Practice that adapts to you
Practise this lesson
13 questions about 5 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Interview questions
A warm-up opens this part, with its whole answer. Harder questions on the same ground are in the module’s question bank: a job runner whose zombies pile up although its SIGCHLD handler collects children, and a program that stops running when the session that started it ends.
-
Fresher · Warm-up
What is a zombie process, and how is it different from an orphan?
A zombie has exited, but its parent has not collected its exit status yet. The kernel has freed its memory and closed its files and keeps only its row in the process table, with its process ID and its status, so that the parent can still ask how it ended; ps shows it in state Z, and the row goes away when the parent calls wait, waitpid or waitid. An orphan is the opposite case: it is still running, but its parent has exited. The kernel gives it a new parent at once, PID 1 or on Linux the nearest subreaper ancestor, and that new parent waits for it when it exits. So zombies come from parents that never wait, while orphans are harmless: their new parent collects them. A program that forks must therefore collect its children, or ignore SIGCHLD if it does not need their statuses.
Key takeaways
- A process ends by exit or by a signal; the kernel frees its memory and files, sends SIGCHLD to the parent and keeps only its ID and its status, whose word holds the signal in its low 7 bits and the exit status in bits 8 to 15, so exit(256) reads as 0.
- A zombie is the row the kernel keeps until the parent collects the status: it still holds its PID, so kill(pid, 0) succeeds, and waitpid removes it.
- A SIGCHLD handler must loop on waitpid with WNOHANG, because signals do not queue; setting SIGCHLD to SIG_IGN stops zombies from being made, at the cost of the statuses.
- An orphan is adopted at once by PID 1, or on Linux by its nearest subreaper ancestor, which waits for it, so it never stays a zombie for long.
- A process group lets one signal reach a whole job, and a session ties groups to a terminal; a daemon detaches with fork and setsid, or is started by a service manager in a session of its own.
Terms in this lesson
- daemon
- A program that runs in the background without a controlling terminal, usually as a service. Classically it detaches with fork and setsid; today a service manager such as systemd or launchd starts it.
- orphan process
- A process whose parent has exited while it is still running. The kernel gives it a new parent: PID 1, or on Linux the nearest ancestor that has made itself a subreaper.
- process group
- A set of processes that can be signalled together, such as the commands of one shell pipeline. kill with a negative process ID signals every process of a group.
- reaping
- Collecting a terminated child's exit status with wait, waitpid or waitid. It removes the child's zombie entry from the process table and frees its process ID.
- session
- A set of process groups, usually all those started from one terminal. One group at a time is in the foreground and gets the signals the terminal sends for keys such as Ctrl-C.
- SIGCHLD
- The signal the kernel sends a parent when one of its children exits, stops or continues. Its default action is to ignore it, but a handler can use it to collect children.
- subreaper
- On Linux, a process that has called prctl with PR_SET_CHILD_SUBREAPER. Orphans below it in the process tree are given to it as their parent instead of to PID 1.
- zombie process
- A process that has exited but whose exit status its parent has not collected yet. It keeps its process ID and a small entry in the process table, but no memory.
References
- POSIX.1-2024: _Exit, _exit, terminate a process (consequences of process termination) (The Open Group and IEEE)
- POSIX.1-2024: wait, waitpid, wait for a child process to stop or terminate (The Open Group and IEEE)
- POSIX.1-2024: kill, send a signal to a process or a group of processes (The Open Group and IEEE)
- POSIX.1-2024: sigaction, examine and change a signal action (The Open Group and IEEE)
- POSIX.1-2024 Base Definitions, chapter 3: Definitions (Zombie Process, Process Group, Session) (The Open Group and IEEE)
- POSIX.1-2024 Shell Command Language: 2.8.2 Exit Status for Commands (The Open Group and IEEE)
- Bash Reference Manual: Exit Status (GNU Project (Free Software Foundation))
- daemon(7): writing and packaging system daemons (Linux man-pages project (man7.org))
- PR_SET_CHILD_SUBREAPER(2const): set the child subreaper attribute (Linux man-pages project (man7.org))
- pid_namespaces(7): overview of Linux PID namespaces (the namespace init process) (Linux man-pages project (man7.org))
- systemd(1): systemd system and service manager (Linux man-pages project (man7.org))
- The life cycle of a daemon (launchd) (Apple)
- less(1): the -K option and the interrupt character (Linux man-pages project (man7.org))
- setsid(2): create a session and set the process group ID (Linux man-pages project (man7.org))
- signal(7): overview of signals (Linux man-pages project (man7.org))
- ps(1): report a snapshot of the current processes (process state codes) (Linux man-pages project (man7.org))
- proc_pid_stat(5): status information about the process (Linux man-pages project (man7.org))
- XNU bsd/sys/wait.h: the W* macros on macOS (Apple Open Source (XNU source code))
- GNU C Library bits/waitstatus.h: the W* macros on Linux (GNU C Library (source code mirror))
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress