Operating Systems · Processes and Threads

Zombies, Orphans, and the Mechanics of Reaping

Why an exited process keeps a task structure, what the wait call does to release it, and how subreapers keep orphaned descendants from accumulating.

A process that has called exit and finished is gone in every sense that matters to the program: its threads have stopped, its address space has been released, its open file descriptors have been closed, and its children have been reparented. What remains is a task descriptor holding a few words: the exit status, the resource usage counters, and the identity of the parent. That residue is a zombie, and its existence is a deliberate consequence of an interface design in which the parent is the party that collects the result.

Why the Exit Status Cannot Be Delivered Eagerly

The obvious alternative is to deliver the status to the parent at the moment the child exits, then free everything. The obstacle is that the parent may not be waiting, may not be scheduled, and may never call a wait function at all. An exit status is not a message that can be dropped if the recipient is not listening; the parent’s future behaviour may depend on it, and the parent may query it long after the child is gone.

Storing the status in a structure that outlives the process is the simplest way to make the interface work. The zombie therefore holds exactly what a later wait call needs: the identifier, the termination reason, the exit code, and the accumulated CPU time and page-fault counts that resource accounting requires. Nothing else survives, because everything else can be released without loss.

The cost is bounded per zombie and unbounded in aggregate. Each zombie consumes a task descriptor and a process-table entry. A program that forks repeatedly and never reaps accumulates one entry per child, and the process identifier space is finite; on a system with a 16-bit identifier space the space wraps at a few tens of thousands, and a zombie leak will eventually prevent new processes from being created, not just consume memory.

What wait Does, Step by Step

A wait call scans the children of the calling process for one that has changed state. The scan is over the child list of the parent, not over all processes, so its cost is proportional to the number of children rather than to the size of the process table.

For a child that has exited, the call reads the status out of the zombie’s descriptor, records the resource usage into the parent’s accumulated counters, removes the descriptor from the process table, and frees it. The identifier becomes available for reuse. The parent’s child list loses an entry. If the parent had registered a handler for the child-death signal, that handler runs at a point the kernel chooses, subject to the usual signal rules.

For a child that has stopped or continued, the call reports the state change without freeing anything. A stopped child is still alive and will resume; a continued child likewise. The same call therefore serves two purposes, and the flags that select which changes to report determine whether a stopped child’s notification consumes the call. A wait that reports a stop and returns leaves the child unreaped, and the next wait must be prepared to see it again.

The status word is not the exit code. It is a packed integer whose bits encode whether the child exited normally, was killed by a signal, or stopped, and which signal or code applies. The macros that decode it exist because the packing is an interface detail that programs should not depend on. A program that compares the raw word to zero will misread a child that exited with status zero from a signal.

#include <sys/wait.h>
#include <unistd.h>
#include <stdio.h>

int main(void) {
    pid_t pid = fork();
    if (pid == 0) {
        _exit(3);
    }
    int status;
    pid_t done = waitpid(pid, &status, 0);
    if (done == pid && WIFEXITED(status)) {
        printf("exit code %d\n", WEXITSTATUS(status));  /* 3 */
    }
    return 0;
}

Orphans and the Reparenting Rule

A child whose parent exits first becomes an orphan. The kernel does not leave it parentless: it is reparented to a designated process, traditionally the init process with identifier 1, or to the nearest ancestor that has declared itself a subreaper. The new parent is then responsible for reaping it when it exits.

Reparenting is what makes daemonization work. A daemon forks, the parent exits, the child is reparented to init, and the child continues with no controlling terminal and no parent to wait on it. Init’s reaping loop collects it when it finally exits. Without reparenting, the child would have a dangling parent reference and no one to collect its status. The detach from the terminal that usually accompanies this sequence is a separate operation, described in Process Groups, Sessions, and the Controlling Terminal.

The choice of reaper matters for containers and for process supervisors. A supervisor that forks workers and expects to reap them will fail if a worker double-forks and the intermediate exits, because the grandchild is reparented past the supervisor to init, outside the supervisor’s child list. The subreaper mechanism exists to fix this: a process marks itself as the nearest reaping ancestor for its descendants, so that an orphaned grandchild is reparented to it rather than skipping to init. The supervisor can then wait for every process it started, however many generations down.

A subreaper must actually reap. The declaration only changes where orphans go; it does not collect them automatically. A supervisor that sets the flag and then blocks forever in an unrelated wait accumulates zombies among its adopted descendants.

Signals and the Race With wait

The child-death signal exists so that a parent does not have to poll. The default disposition is to ignore it, which is why a program that never installs a handler never sees it and never learns of a death until it calls wait. Installing a handler introduces a race that has bitten every generation of Unix programmers: the signal may arrive after the child exits but before the parent calls wait, and a handler that does nothing but set a flag is fine, while a handler that calls wait itself may consume the status the main flow was about to collect.

The classic failure is a handler installed without the restart semantics the platform provides. On a system where interrupted system calls return an error rather than restarting, a read or accept in the main loop fails when the handler runs, and the program treats an interrupted call as a fatal error. The fix is either to install the handler with the flag that requests restart, or to check for the interruption error and retry.

A second race is the window between fork and the installation of the handler, or between the handler’s installation and the first wait. A child that exits inside that window leaves a zombie that the handler will not be told about, because the signal was delivered before the handler existed. The standard shape that closes both races is to block the signal before forking, install the handler, and unblock afterwards, so that no notification can be missed between the two operations.

There is also a resource-exhaustion path that produces the same symptom as a leak. A program that installs a handler for the child-death signal and does not reap inside it receives one notification per death; if the handler is not installed or the signal is blocked, no notification arrives and the zombies accumulate silently. The observable difference is that the leak with a handler grows only while deaths are unprocessed, whereas the leak without one grows without bound.

Reaping Without Blocking

A supervisor that manages several children cannot block on any one of them. The wait call accepts a value that means “any child” and returns whichever changed state first, which is the primitive a supervision loop needs: wait for any, then act on the identifier returned. The loop is a single wait in a loop, and the cost is one system call per state change rather than one per child per poll interval.

The alternative, polling with a zero timeout, is worse in both directions: it burns a system call per poll and it introduces a latency floor equal to the poll interval. It survives in code that must also service other descriptors, and the usual remedy there is to have the child-death signal interrupt the multiplexing call so that the loop wakes exactly when a child changes state.

The details of how that signal reaches a specific thread in a multithreaded supervisor are not obvious, because the signal is process-directed and any thread with it unblocked may run the handler. A program that installs the handler and then blocks the signal in every thread but one gets deterministic behaviour, at the cost of one thread dedicated to reaping. The rules that govern that choice are described in Signal Delivery, Thread Masks, and Which Thread Runs the Handler.

A reaping supervisor must also handle the case where a child was already reaped by a library. The C library’s system and popen calls create children and reap them internally. A program that installs a global child-death handler will receive notifications for those internal children and may consume their statuses before the library does, which produces spurious failures in the library call. The standard mitigation is to block the signal around library calls that create children, or to use the library functions’ own result reporting rather than a global handler.

None of this is visible in the process’s own view. A zombie has no threads, cannot be signalled meaningfully, and does not appear in a directory listing of running tasks except as a state letter. It is a bookkeeping entry waiting for a reader, and the reader is the parent, which is why the responsibility for cleanup sits with the code that created the child rather than with the child itself.

References

  1. wait(2) — wait for process to change state — Linux man-pages project
  2. POSIX.1-2017, System Interfaces — wait() and waitpid() — IEEE Std 1003.1-2017
  3. The Design and Implementation of the FreeBSD Operating System — Chapter 4, Process Management — McKusick, Neville-Neil, Watson; Addison-Wesley
  4. exit(3) — cause normal process termination — Linux man-pages project