Systems Programming · OS Interfaces

Exit Status, Zombies, and What waitpid Can Tell a Parent

A terminated process leaves a status word and an entry that must be reaped. Status encoding, the reason the record survives termination, and the four ways a parent can wait without deadlocking.

When a process ends, the kernel cannot simply delete it. Its parent may still want to know how it ended, and the exit status is the only channel for that information. So the process is moved to a terminal state: its memory and descriptors are released, but a small record survives holding the exit code or the signal that killed it. This record is the zombie, and the call that collects it is waitpid. The whole design follows from one constraint — the parent must be able to retrieve the status, and must not be able to lose it by asking at the wrong time.

The encoded status word

wait and waitpid return status through an int pointer, and that integer is not the exit code. It packs several facts into a bitfield whose layout is implementation-defined but whose accessors are standard. WIFEXITED tests whether the child terminated normally, WEXITSTATUS extracts the low eight bits of the exit code in that case, WIFSIGNALED tests whether it was killed by a signal, and WTERMSIG extracts the signal number. WIFSTOPPED and WSTOPSIG handle the case where the child was stopped rather than terminated, which requires the parent to have requested stop notifications.

The eight-bit limit on the exit code is real and not a convention. exit(256) is truncated to zero, so a program that returns a value from main and a program that calls exit with that value can produce the same status. Codes above 255 are not portable and are silently reduced. Shells compound this by reserving codes: 126 and 127 report a command that could not be executed and a command not found, and 128 plus the signal number reports death by signal, which is why 130 usually means SIGINT.

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

int main(void) {
    pid_t pid = fork();
    if (pid == 0) _exit(3);        /* skip atexit handlers and stdio flush */

    int status;
    if (waitpid(pid, &status, 0) < 0) { perror("waitpid"); return 1; }

    if (WIFEXITED(status))
        printf("exited with %d\n", WEXITSTATUS(status));   /* 3 */
    else if (WIFSIGNALED(status))
        printf("killed by signal %d\n", WTERMSIG(status));
    return 0;
}

The distinction between _exit and exit matters here. exit runs handlers registered with atexit, flushes standard I/O streams, and then calls _exit. In a child created by fork, the buffered output inherited from the parent has not been flushed, and calling exit flushes it a second time, duplicating whatever the parent had pending. _exit skips all of that, which is why the child branch of a fork normally uses it, or calls execve, which discards the buffers entirely.

Why the record cannot be freed immediately

The zombie exists because the status must be durable until the parent asks. If the kernel freed the record at termination, the parent would have to poll continuously to avoid missing it, and a parent that was itself busy would lose the information.

The record is small — a process table slot holding the pid, the status word, resource usage counters, and the parent link — but it is not free. A process that spawns children and never waits accumulates them. On Linux they appear in ps with state Z, and the pid cannot be reused while the record exists, so a long-running program that leaks zombies eventually exhausts its available pids and cannot fork at all. The failure surfaces as EAGAIN from fork, which reads like a resource limit but is actually a reaping problem.

The alternative design, freeing the record immediately and delivering the status asynchronously, was not chosen because it has no natural place to put the status when the parent is not waiting. SIGCHLD exists and is delivered on termination, but signals are not queued by default: if two children die while the handler is blocked, the handler runs once. The status of the second child is still recoverable, because the zombie record holds it, which is precisely why the record must exist.

Four ways to wait, and how each can hang

wait blocks until any child terminates. waitpid takes a specific pid, or -1 for any child, and a set of options. The options are what make it usable in a program that must keep doing other work.

With no options, waitpid blocks. If the child has already terminated and is a zombie, it returns immediately. If the child does not exist, it returns -1 with ECHILD. If the child exists but is still running, it blocks, which is correct for a parent whose only job is to supervise.

WNOHANG makes the call non-blocking. It returns 0 if any matching child is still running, the pid of a reaped child if one has terminated, and -1 with ECHILD if no matching children exist. A supervisor loop typically calls this in a loop until it returns 0, then continues with other work. The distinction between 0 and ECHILD is essential: a loop that treats both as “nothing to do” never notices when all children are gone.

WUNTRACED and WCONTINUED extend the call to stopped and resumed children, which requires the parent to check WIFSTOPPED and WIFCONTINUED in addition to the termination cases. These options are what a shell uses to implement job control: the shell is notified when a foreground job is stopped by SIGTSTP, and it reports the job as stopped rather than exited.

The pid argument has three special values beyond a specific pid. -1 matches any child. 0 matches any child in the same process group as the caller, which is what a shell uses to wait for the current foreground job without catching a background job’s termination. A negative value less than -1 matches any child whose process group equals the absolute value, which lets a supervisor wait on a whole group at once.

#include <errno.h>
#include <sys/wait.h>

/* Reap everything that has finished, without blocking. */
static void reap_ready(void) {
    for (;;) {
        int status;
        pid_t pid = waitpid(-1, &status, WNOHANG);
        if (pid > 0)        { record_exit(pid, status); continue; }
        if (pid == 0)         break;          /* children still running */
        if (errno == ECHILD)  break;          /* no children at all */
        if (errno == EINTR)   continue;       /* interrupted by a signal */
        break;                                /* any other error */
    }
}

The EINTR branch is not optional. With a SIGCHLD handler installed and no SA_RESTART, the blocking wait is interrupted by the very signal that reports a child’s death, and the loop must go around again.

SIGCHLD and the race it creates

SIGCHLD is delivered when a child terminates, stops, or resumes, and it is the event a supervisor uses to learn that waitpid will now return. The signal is not a substitute for the call, for two reasons.

First, delivery is not queued. A process that forks ten children in quick succession and installs a handler that calls waitpid once per delivery may receive one signal and reap one child, leaving nine zombies. The correct handler loops on WNOHANG until it returns 0, which reaps all of them in one delivery. Second, the signal can be delivered while the parent is between the check and the blocking call. A parent that tests “is any child done?” and then calls waitpid without WNOHANG can block forever if the child died in the gap and the signal was handled before the call.

The atomic solution is sigsuspend: block SIGCHLD, test the condition, then call sigsuspend with the original mask, which unblocks and waits in one operation. The handler then runs before the parent resumes, and the condition is rechecked. The related race for readiness on descriptors is handled by pselect and ppoll, described in readiness notification.

A handler that calls waitpid must also be careful about what else it touches. Only a small set of functions are safe to call from a signal handler, and waitpid is among them. printf is not, and a handler that logs the status with printf can deadlock if the signal arrived while the main program held the stream’s lock. The constraints are examined in signal handling and async-signal safety.

What happens when the parent dies first

The parent link is what determines who reaps. If a parent terminates before its children, the children are reparented. On Linux the default is to reparent to the nearest ancestor that has marked itself as a subreaper with prctl(PR_SET_CHILD_SUBREAPER), and failing that, to pid 1. Traditional Unix reparents to init unconditionally.

The reparented child is adopted, and its eventual termination produces a zombie owned by the new parent, which reaps it. Init does this continuously. A subreaper exists because init is far away and may not know anything about the adopted process; a container runtime or a session manager can declare itself the subreaper and receive the status of orphaned descendants, which is how a supervisor inside a container avoids leaving zombies that nothing will reap.

The status of a process that was already a zombie when its parent died is not lost. The record moves with the child, and the new parent can reap it. The pid remains reserved until then, so an orphaned zombie still occupies a slot.

The same terminal state applies to threads, with a difference: a thread’s exit status is retrieved by another thread in the same process with pthread_join, and there is no zombie in the process table. The thread’s resources are held until the join, which is the same bargain in a smaller scope. A detached thread has no join and its resources are released at exit. The process-level equivalent of that trade-off — a handle that can be waited on without the pid-reuse race — is covered in pidfd and race-free process handles.

References

  1. wait(2) — Linux manual page — man7.org
  2. The Open Group Base Specifications Issue 7, 2018 edition — System Interfaces — The Open Group
  3. Advanced Programming in the UNIX Environment — W. Richard Stevens and Stephen A. Rago, Addison-Wesley, 3rd edition, 2013
  4. The Linux Programming Interface — Michael Kerrisk, No Starch Press, 2010