Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

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.

  • Intermediate
  • 15 minutes
  • Examples run with Python 3.14.8, Pyodide 314.0.7, Clang 21.0.0 and GCC 16.2.0
  • By MySmartCoPilot
Free: 15 minutes

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:

Six ways for a child to end, and the status the parent gets C · exit_statuses.c
#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

  1. Question 1 of 2 A parent receives the status word 0x0200 from waitpid. What happened to the child?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: It exited with status 2

    The recorded run gave 0x0300 for exit(3) and 0x000f for SIGTERM: the exit status sits in the second byte, the signal in the low 7 bits.

  2. Question 2 of 2 A child calls exit(300). What exit status does its parent read with WEXITSTATUS?

    Type a number.

    A hint

    Only the low 8 bits arrive; 256 is 1 0000 0000 in binary.

    Show the answer: Question 2 of 2

    Answer: 44 as the status

    300 − 256 = 44, because wait and waitpid keep only status & 0377. The recorded run showed the same with exit(256), which arrived as 0.

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:

A zombie, seen with sysctl and kill(pid, 0), then collected C · zombie.c
#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:

Interactive · step by step A child's row in the process table, from exit to collection Step through zombie.c. Watch what the child keeps after it has exited, and what makes its row go away.
A child's row in the process table, from exit to collection, step 6 of 6. Showing Parent (collected: status 7), Child (exited).SIGCHLDwaitpidParentcollected: status 7Childexited

The drawing shows the whole scene; its 6 steps are listed below.

Every step as text
  1. The child runs. The child has a process ID, memory, open files and a row in the kernel's process table, like any process.
  2. 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.
  3. 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.
  4. 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.
  5. The parent collects. The parent calls waitpid for the child and receives the status, 7. That was all the row was kept for.
  6. 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

  1. Question 1 of 2 What does a zombie process still hold?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: Its row in the process table, with its PID and its exit status, but no memory

    A zombie is the remains of a process before its status is consumed, in POSIX's words; the row is what the parent's wait reads, and waiting removes it.

  2. Question 2 of 2 A child has exited and its parent has not waited yet. What does kill(child, 0) return, and what does it return after the parent's waitpid?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: 0 while it is a zombie, because the PID still exists; -1 with ESRCH after waitpid

    kill with signal 0 is an existence check. POSIX warns that it cannot detect a child's termination for that reason; waitpid and waitid are the way to learn that a child has ended.

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:

A SIGCHLD handler that collects every child, then SIGCHLD ignored C · reaper.c
#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

  1. Question 1 of 2 Why should a SIGCHLD handler loop on waitpid(-1, &status, WNOHANG) instead of calling waitpid once?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: Signals of one kind do not queue, so one SIGCHLD may stand for several exited children; WNOHANG makes the loop stop when none is left

    The handler of reaper.c collected all five children, whatever number of SIGCHLDs actually arrived, because each run of it emptied every exited child.

  2. Question 2 of 2 A parent sets SIGCHLD to SIG_IGN, then forks three children that exit. What happens to them, and what does a later wait return?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: They never become zombies; wait returns -1 with ECHILD, because nothing is left to collect

    SIG_IGN on SIGCHLD, or the SA_NOCLDWAIT flag, makes the kernel discard children at exit. It prevents zombies, but the parent can no longer learn how its children ended.

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:

One program · 14 languages Zombies and orphans in a model of the process table

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

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

  1. Question 1 of 2 On a Linux system with no subreaper, the parent of a running process exits. Which process becomes its parent?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: PID 1, the system's init process

    The boot lesson's program saw it happen: an orphan's parent became process 1, which waits for every child it adopts.

  2. Question 2 of 2 In the model, the server 11 exited while its worker 13 was still running. What happened to 13 then, and who collected its status when it exited?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: PID 1 adopted it at once, and collected its status as soon as it exited

    The model printed "13 is adopted by 1" in the step of 11's exit, and "1 collects 13: status 1" in the step of 13's own exit, so 13 never appeared in a listing as a zombie.

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:

Interactive · message by message Ctrl-C reaches a whole pipeline, and not the shell Step through a shell running yes | grep y | wc -l in the foreground, and the key press that ends it.
Ctrl-C reaches a whole pipeline, and not the shell, step 5 of 5. Messages so far: Shell to Pipeline group: fork, setpgid; Shell to Terminal driver: foreground: the job; Terminal driver to Pipeline group: SIGINT to the group.Shellprompt againTerminal driverin the kernelPipeline groupended by SIGINTfork, setpgidforeground: the jobSIGINT to the group

The drawing shows the whole scene; its 5 steps are listed below.

Every step as text
  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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:

One signal for a group of three, and a new session C · process_group.c
#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

  1. Question 1 of 2 What does kill(-42, SIGTERM) do?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: Sends SIGTERM to every process whose process group ID is 42

    process_group.c ended three children with one kill(-group, SIGTERM), the same way a shell signals a whole job.

  2. Question 2 of 2 Why does the classic daemon recipe fork before calling setsid?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: setsid fails for a process group leader, and a newly forked child can never be one

    A shell often makes the process it starts the leader of a new group, so the program forks and lets the child, a member of that group but not its leader, call setsid. daemon(7) adds a second fork, so that the daemon can never acquire a terminal again.

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, and 0x7f if 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.

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.

Number Base Converter Turn a status word such as 0x0300 or 0x8b into binary and see which bits hold the signal and the exit status.

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.

  1. Question 1 of 13 Which of these ends a process normally, so that WIFEXITED is true?

    Choose one answer.

    Show the answer: Question 1 of 13

    Answer: Returning from main

    A normal end is exit, _exit or a return from main; anything that ends a process by a signal is reported with WIFSIGNALED and WTERMSIG.

  2. Question 2 of 13 Bash reports exit status 137 for a command. Which signal ended the command?

    Type a number.

    A hint

    Bash uses 128 plus the signal's number.

    Show the answer: Question 2 of 13

    Answer: 9 signal number

    137 − 128 = 9, SIGKILL. POSIX requires only a status greater than 128 for a command ended by a signal; bash's rule is 128 plus the signal number.

  3. Question 3 of 13 A backup script treats any exit status other than 0 as a failure. The tool it runs returns the number of files it skipped. What goes wrong when it skips exactly 512 files?

    Choose one answer.

    Show the answer: Question 3 of 13

    Answer: The script sees status 0 and reports success, because only the low 8 bits arrive and 512 has none set

    An exit status says success or a kind of failure, not a count: the recorded exit(256) arrived as 0 in the same way.

  4. Question 4 of 13 What does ps show in its STAT column for a zombie?

    Choose one answer.

    Show the answer: Question 4 of 13

    Answer: Z, which ps(1) describes as defunct

    zombie.c read the same letter from the kernel through sysctl on macOS; on Linux it is the third field of /proc/<pid>/stat.

  5. Question 5 of 13 A long-running server forks a child for every request and never waits for its children. What happens over the following days?

    Choose one answer.

    Show the answer: Question 5 of 13

    Answer: Zombies pile up, using process IDs and table slots, until fork starts to fail

    The fix is to collect children, in a SIGCHLD handler that loops with WNOHANG, or to set SIGCHLD to SIG_IGN when the statuses are not needed.

  6. Question 6 of 13 From a shell, how would you find out whether process 4123 has zombie children?

    Choose one answer.

    Show the answer: Question 6 of 13

    Answer: List processes with ps -o pid,ppid,stat and look for state Z with parent 4123

    The process table is where zombies live, so a listing of it with the state and the parent of each process shows them, and their parent, which is the process to fix.

  7. Question 7 of 13 Which waitpid flag makes the call return at once when no child has exited yet?

    Choose one answer.

    Show the answer: Question 7 of 13

    Answer: WNOHANG

    WNOHANG is what lets a SIGCHLD handler loop until nothing is left, without ever blocking the program.

  8. Question 8 of 13 A handler calls waitpid(-1, NULL, 0) once per SIGCHLD. Three children exit at nearly the same moment, and one SIGCHLD is delivered for them. How many zombies are left?

    Type a number.

    Show the answer: Question 8 of 13

    Answer: 2 zombies

    One call collects one child, and the other two stay zombies until some later signal or a wait collects them. Looping with WNOHANG would have collected all three.

  9. Question 9 of 13 What is an orphan process?

    Choose one answer.

    Show the answer: Question 9 of 13

    Answer: A process whose parent has exited while it is still running

    Orphans and zombies are opposites: an orphan is alive without its parent, a zombie is dead with its parent alive.

  10. Question 10 of 13 A service manager has made itself a subreaper. A service it started forks a daemon and exits. Which process becomes the daemon's parent?

    Choose one answer.

    Show the answer: Question 10 of 13

    Answer: The service manager, the nearest living ancestor that is a subreaper

    This is how a service manager keeps track of a service even when it double-forks: every orphan below it comes back to it.

  11. Question 11 of 13 Why does an orphan never stay a zombie for long, even if its original parent never waited for anything?

    Choose one answer.

    Show the answer: Question 11 of 13

    Answer: Its new parent, PID 1 or a subreaper, waits for every child it adopts

    The model showed it: 1 adopted 13 when 11 exited and collected it the moment 13 exited, so 13 never appeared in a listing as a zombie.

  12. Question 12 of 13 What does a successful setsid make of the calling process?

    Choose one answer.

    Show the answer: Question 12 of 13

    Answer: The leader of a new session and of a new process group, with no controlling terminal

    The recorded run checked all of it: the child was in its parent's session before, led a new session and a new group after, and a second setsid failed with EPERM.

  13. Question 13 of 13 Why does Ctrl-C end all three commands of yes | grep y | wc -l but not the shell that started them?

    Choose one answer.

    Show the answer: Question 13 of 13

    Answer: The terminal sends SIGINT to every process of its foreground process group, and the shell is in another group

    Process groups exist so that one signal can reach a whole job; the session ties the groups to the terminal that sends them.

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.

  1. 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

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.