skip to content

Forking & Signals

The pcntl and posix extensions let a CLI script fork children, reap them, and react to SIGTERM. Interviewers ask how you would write a worker or daemon that shuts down cleanly.

part ofPHPoverview, primer and where to startread it →
on this pageshow

explore

questions

5

In a PHP CLI parent that forks workers with pcntl_fork(), how do you reap finished children and read how each one ended?

level: middleimportance: should knowfreq 20%

answer

  1. the parent must wait for each child
  2. pcntl_wait() blocks for any child
  3. WNOHANG returns 0 instead of blocking
  4. status is decoded, not read directly
  5. pcntl_wifexited() before pcntl_wexitstatus()

basics

~10 s

Call pcntl_wait() or pcntl_waitpid() for every child: they return the PID of a child that ended and fill a status integer. Decode it with pcntl_wifexited()/pcntl_wexitstatus() or pcntl_wifsignaled()/pcntl_wtermsig(); WNOHANG makes the call non-blocking.

solid answer

~40 s

A parent collects each finished child with `pcntl_wait($status)` (any child) or `pcntl_waitpid($pid, $status, $flags)` (a given PID, or `-1` for any). Both return the reaped child's PID, `-1` on error (for example when no children are left), and with the `WNOHANG` flag `0` when no child has ended yet, so a supervisor can poll in its main loop. The `$status` argument is filled by reference with a raw status word, which you decode: `pcntl_wifexited($status)` then `pcntl_wexitstatus($status)` for the exit code, or `pcntl_wifsignaled($status)` then `pcntl_wtermsig($status)` for the signal that killed it. A parent that never waits leaves finished children in the process table. To stop a child, send it `posix_kill($pid, SIGTERM)` and then wait for it.

code

php · 18 lines
php
<?php
declare(strict_types=1);

/** @param array<int, string> $workers pid => name */
function reapFinished(array &$workers): void
{
    while (($pid = pcntl_waitpid(-1, $status, WNOHANG)) > 0) {
        $name = $workers[$pid] ?? '?';
        unset($workers[$pid]);

        if (pcntl_wifexited($status)) {
            printf("%s exited with %d\n", $name, pcntl_wexitstatus($status));
        } elseif (pcntl_wifsignaled($status)) {
            printf("%s killed by signal %d\n", $name, pcntl_wtermsig($status));
        }
    }
    // loop ends on 0 (children still running) or -1 (none left)
}

go deeper

for a junior

Recall that a parent must call pcntl_wait() or pcntl_waitpid() for each child, and that pcntl_wexitstatus() reads the exit code out of the status value.

for a middle

Explain the return values (PID, 0 with WNOHANG, -1 on error), why status must be decoded with the pcntl_wif* functions, and how a non-blocking reaping loop is written.

for a senior

Describe a supervisor that reaps in its main loop, distinguishes crashes by signal from clean exits, restarts workers, and escalates from SIGTERM to SIGKILL after a grace period.

for a principal

Decide whether hand-written pcntl supervision is worth owning, or whether an external process manager should restart workers and PHP should run one worker per process.

## Why a parent has to wait When a child process ends, the operating system keeps a small record of it — its PID and how it ended — until the parent asks for that record. Asking is called **reaping**. A PHP parent that forks workers with `pcntl_fork()` and never reaps them leaves those records behind; a long-running supervisor that does this for days slowly fills the process table. (The process-state theory behind this belongs to the Linux process topics; here the question is which PHP functions do the reaping.) ## The two wait functions Both come from the pcntl extension and take the status by reference: - `pcntl_wait(&$status, int $flags = 0, &$resource_usage = []): int` — waits for **any** child. - `pcntl_waitpid(int $process_id, &$status, int $flags = 0, &$resource_usage = []): int` — waits for a specific child, or for any child when `$process_id` is `-1`. Other values select children by process group. Their return values are the same: | Return | Meaning | |---|---| | a positive PID | that child ended and has now been reaped; `$status` describes it | | `0` | only with `WNOHANG`: children exist but none has ended yet | | `-1` | error — most often that there are no children left to wait for | With the default `$flags = 0` the call **blocks** until a child ends. The `WNOHANG` flag turns it into a poll, which is what a supervisor with its own main loop needs. The optional `$resource_usage` array receives the child's resource usage figures, such as `ru_maxrss`, where the platform supports it. ## Decoding `$status` The integer written into `$status` is not the exit code. It packs several facts together, and pcntl provides one function per fact: 1. `pcntl_wifexited($status)` — `true` if the child ended normally through `exit()` or by reaching the end of the script. 2. `pcntl_wexitstatus($status)` — the exit code; meaningful only when `pcntl_wifexited()` is `true`. 3. `pcntl_wifsignaled($status)` — `true` if a signal killed the child. 4. `pcntl_wtermsig($status)` — which signal did it, for example `SIGKILL` from an out-of-memory kill. 5. `pcntl_wifstopped()` and `pcntl_wstopsig()` — the child was stopped rather than ended (reported only when you pass `WUNTRACED`). Reading `pcntl_wexitstatus()` without first checking `pcntl_wifexited()` gives a meaningless number for a child that was killed by a signal. ## The supervisor loop A typical pre-fork supervisor keeps a map from PID to worker slot and reaps without blocking on each pass: - call `pcntl_waitpid(-1, $status, WNOHANG)` in a `while` loop until it returns `0` or `-1`, because several children may have ended since the last pass; - for each reaped PID, remove it from the map and decide whether to start a replacement: restart after a crash, but not while shutting down; - log the exit code or terminating signal so crashes are visible. ## Stopping children To shut a pool down, the parent signals each child and then waits for it: - `posix_kill(int $process_id, int $signal): bool` sends a signal and returns `false` on failure (read the reason with `posix_get_last_error()` and `posix_strerror()`); - send `SIGTERM` first so each child can finish its current job; - then reap with a blocking `pcntl_waitpid($pid, $status)`, or poll with `WNOHANG` and escalate to `SIGKILL` if a child has not ended within your grace period; - `posix_kill($pid, 0)` sends nothing but reports whether the process exists and may be signalled, which is a cheap liveness check. ## Common mistakes - Calling `pcntl_wait()` once when several children have ended — only one is reaped per call. - Treating `-1` as "no child finished yet" and spinning on it; without `WNOHANG`, `0` is never returned, and `-1` usually means nothing is left. - Checking only exit codes, so a worker killed by a signal is reported as "exit 0". - Forgetting that each child reaps its **own** children; a parent cannot reap grandchildren.

  • Why loop on pcntl_waitpid(-1, $status, WNOHANG) instead of calling it once per pass?
    Each call reaps at most one child. If three workers ended since the last pass, a single call collects one and leaves two unreaped until later passes. Looping until the call returns `0` (children still running) or `-1` (none left) collects everything that has finished right now.
  • A worker's pcntl_wexitstatus() reads 0, yet it clearly crashed. What did the parent miss?
    It skipped `pcntl_wifexited()`. A child killed by a signal did not exit normally, so its exit-status bits carry no meaning. Check `pcntl_wifsignaled($status)` and read `pcntl_wtermsig($status)` — a `SIGKILL` there often means the kernel's out-of-memory killer or a supervisor's hard stop.

saying these in an interview costs you the question

  • The status argument of pcntl_wait() is the child's exit code
  • pcntl_waitpid() returns 0 when there are no children left
  • One pcntl_wait() call reaps every child that has ended
  • Finished children disappear by themselves, so a parent never needs to wait
  • posix_kill() kills the process; it cannot send other signals
open as a page

In a PHP CLI script, why might a pcntl_signal() handler never run, and how do pcntl_async_signals() and pcntl_signal_dispatch() fix that?

level: middleimportance: should knowfreq 30%

basics

~10 s

pcntl_signal() only makes PHP queue the signal; the PHP callback runs when the queue is dispatched. Asynchronous dispatch is off by default, so either call pcntl_async_signals(true) or call pcntl_signal_dispatch() regularly in the loop.

open as a page

How would you make a PHP CLI queue-consumer daemon finish its current job and exit cleanly when its process manager sends SIGTERM?

level: seniorimportance: should knowfreq 33%

basics

~20 s

Enable pcntl_async_signals(true), register SIGTERM (and SIGINT) handlers that only set a stop flag, check the flag between jobs, keep blocking waits short, and exit(0) after the current job is acknowledged — within the manager's grace period before SIGKILL.

open as a page

In PHP's pcntl extension, what does pcntl_fork() return, and how do the parent and the child tell which one they are?

level: juniorimportance: nice to knowfreq 18%

basics

~20 s

pcntl_fork() returns the new child's PID in the parent, 0 in the child, and -1 in the parent if the fork failed. Both processes continue from the line after the call, each with its own copy of every variable.

open as a page

A PHP CLI script opens a PDO connection and then calls pcntl_fork() to start four workers that each run queries; what breaks, and how do you fix it?

level: seniorimportance: nice to knowfreq 22%

basics

~20 s

Every child inherits the same database socket, so their queries interleave on one connection and corrupt it, and a child's exit can close the session the parent still uses. Open connections after pcntl_fork(), separately in each process.

open as a page