Fork, wait and signal handling in practice
Process parallelization with pcntl lets PHP CLI scripts create real operating system processes and actually run CPU-bound work concurrently across multiple cores. Anyone who masters pcntl_fork, pcntl_wait and signal handling can build worker pools that drain queues, process batch jobs and shut down cleanly instead of leaving zombie processes behind.
Table of Contents
- 1. Why pcntl exists for process parallelization
- 2. pcntl_fork: how a child process is created
- 3. pcntl_wait and pcntl_waitpid: avoiding zombies
- 4. Building a worker pool with a fixed process count
- 5. Signal handling: SIGTERM, SIGCHLD and pcntl_async_signals
- 6. Inter-process communication: what pcntl does NOT solve
- 7. Memory isolation: copy-on-write and shared resources
- 8. Designing proper error handling in child processes
- 9. pcntl compared to Fibers, Swoole and queue workers
- 10. Summary
- 11. FAQ
1. Why pcntl exists for process parallelization
Process parallelization using the PHP pcntl extension solves a problem neither Fibers nor ReactPHP address: true parallel execution across multiple CPU cores at once. Fibers and event loops are cooperative and run inside a single thread, which is enough for I/O bound tasks but brings no speed benefit for CPU intensive work such as image processing, encryption or complex calculations. pcntl instead creates real, independent operating system processes that the kernel schedules in parallel across different cores.
pcntl stands for process control and is only available on Unix-like systems, the extension is entirely absent on Windows. This is an important limitation for process parallelization: code built on pcntl_fork does not work on Windows development machines, so it must either stay confined to Linux production servers or be combined with a fallback strategy for Windows. In practice this mostly concerns CLI scripts, cron jobs and queue workers that already run on Linux servers anyway.
The central building block of process parallelization with pcntl is pcntl_fork, which duplicates the current process. After the call, two nearly identical processes continue running, each at the exact same point in the code, distinguished only by the return value of pcntl_fork. This way of thinking differs fundamentally from threads or Fibers, where only part of the memory is duplicated. A fork duplicates the entire address space, albeit efficiently via copy-on-write.
2. pcntl_fork: how a child process is created
The call pcntl_fork() is the core of every process parallelization with pcntl. It duplicates the calling process and returns a different value in each of the two resulting processes: in the original parent process, the process ID of the newly created child; in the child process itself, the number 0. If the fork fails, for example because the operating system has hit the maximum process count, the function returns minus 1. These three cases must be explicitly distinguished in every pcntl_fork call.
Right after the fork, two completely independent processes exist with their own memory, their own process ID and their own set of file descriptors, though open file descriptors are initially shared. Changes to variables in the child process do not affect the parent process and vice versa, because the kernel creates a private copy of the affected memory page on the first write, the so called copy-on-write principle. For process parallelization this means shared state between processes requires explicit mechanisms such as shared memory, not simply global variables.
A common mistake on first contact with process parallelization: developers forget that open database connections are duplicated too. A PDO connection opened before the fork exists in both processes as the same underlying socket. If both processes write to it simultaneously, unpredictable protocol errors occur. The safe practice is to open database connections only inside the child process after the fork, never before.
<?php
declare(strict_types=1);
function processTask(int $taskId): void
{
// Simulate CPU-bound work, e.g. image resizing or hashing
$result = hash('sha256', (string) $taskId . random_bytes(1024));
echo "Child " . posix_getpid() . " finished task {$taskId}: {$result}\n";
}
$taskIds = [1, 2, 3, 4];
$childPids = [];
foreach ($taskIds as $taskId) {
$pid = pcntl_fork();
if ($pid === -1) {
// Fork failed — handle gracefully, do not silently continue
fwrite(STDERR, "Fork failed for task {$taskId}\n");
continue;
}
if ($pid === 0) {
// We are in the child process now
processTask($taskId);
exit(0);
}
// We are in the parent process, $pid holds the child's PID
$childPids[] = $pid;
}
echo "Parent " . posix_getpid() . " spawned " . count($childPids) . " children\n";
3. pcntl_wait and pcntl_waitpid: avoiding zombies
When a child process ends, its entry remains in the kernel's process table until the parent process has explicitly collected its exit status. This state is called a zombie process. For short lived scripts this barely matters, but for long running process parallelization that creates many children over hours or days, zombies accumulate until the operating system can no longer assign new process IDs.
pcntl_wait blocks the parent process until any child process terminates and returns its PID. pcntl_waitpid additionally allows waiting for a specific child process and accepts the WNOHANG flag, which makes the function return immediately with 0 if no child has finished yet, instead of blocking. For process parallelization in a worker pool, WNOHANG is essential, because the parent process must keep working while periodically checking whether children have finished.
The exit status that pcntl_wait delivers via reference parameter must be interpreted using helper functions: pcntl_wifexited checks whether the process ended normally, pcntl_wexitstatus returns the actual exit code. Without this interpretation it remains unclear whether a child process succeeded or crashed with an error, which can lead to unnoticed data loss in batch processing with process parallelization.
<?php
declare(strict_types=1);
$runningPids = [/* ... populated by pcntl_fork calls ... */];
$failedTasks = [];
// Non-blocking reap loop: check for finished children without stalling the parent
while (count($runningPids) > 0) {
foreach ($runningPids as $index => $pid) {
$status = 0;
$result = pcntl_waitpid($pid, $status, WNOHANG);
if ($result === 0) {
// Child still running, check again later
continue;
}
if ($result === $pid) {
if (pcntl_wifexited($status)) {
$exitCode = pcntl_wexitstatus($status);
if ($exitCode !== 0) {
$failedTasks[] = $pid;
fwrite(STDERR, "Child {$pid} exited with code {$exitCode}\n");
}
} elseif (pcntl_wifsignaled($status)) {
$signal = pcntl_wtermsig($status);
fwrite(STDERR, "Child {$pid} killed by signal {$signal}\n");
}
unset($runningPids[$index]);
}
}
// Avoid a busy loop that saturates a CPU core
usleep(50_000);
}
echo count($failedTasks) . " tasks failed\n";
4. Building a worker pool with a fixed process count
In practice, you rarely want to start an unlimited number of child processes simultaneously. Effective process parallelization caps the number of concurrent workers at a fixed number, usually based on the available core count via nproc or similar detection. The pattern: work through a queue of tasks while never keeping more than N child processes active at once, and as soon as one child finishes, immediately start the next.
This kind of process parallelization is called a fixed size worker pool. The parent process acts as a supervisor: it distributes tasks, monitors the state of all children via pcntl_waitpid with WNOHANG, and starts new children as needed, for example when a worker has unexpectedly crashed. This supervisor pattern is practically identical to what PHP FPM itself implements internally for its worker processes.
An important design consideration: the number of parallel processes should not be blindly maximized. More processes than physical cores lead to context switches that reduce throughput rather than increase it. For CPU bound work, the core count is a sensible upper limit for process parallelization, whereas for I/O bound work where processes spend a lot of time waiting, a higher count can well make sense.
5. Signal handling: SIGTERM, SIGCHLD and pcntl_async_signals
A production grade worker pool with process parallelization must react to operating system signals, particularly SIGTERM, which Docker sends when stopping a container, and SIGCHLD, which the kernel sends automatically once a child process ends. With pcntl_signal(SIGTERM, $handler) you register a callback to run upon receiving the signal, but you must ensure signals are actually processed.
Before PHP 7.1, developers had to regularly call pcntl_signal_dispatch() manually for registered signal handlers to run at all, a frequent source of bugs in process parallelization. Since PHP 7.1, declare(ticks=1) or, better, pcntl_async_signals(true) enables asynchronous signal processing, so registered handlers run immediately once a signal arrives, without manual dispatching in the main loop.
A clean shutdown in process parallelization means: the parent process catches SIGTERM, forwards the signal to all still running child processes via posix_kill($pid, SIGTERM), waits a bounded amount of time for their orderly termination via pcntl_waitpid, and terminates any children that do not respond in time hard with SIGKILL. Without this cascade, child processes can keep running as orphaned, unsupervised processes after the parent process has already exited.
<?php
declare(strict_types=1);
pcntl_async_signals(true);
$childPids = [/* ... populated by pcntl_fork calls ... */];
$shuttingDown = false;
pcntl_signal(SIGTERM, function (int $signal) use (&$shuttingDown, $childPids): void {
$shuttingDown = true;
fwrite(STDERR, "SIGTERM received, forwarding to " . count($childPids) . " children\n");
foreach ($childPids as $pid) {
posix_kill($pid, SIGTERM);
}
});
pcntl_signal(SIGCHLD, function (int $signal) use (&$childPids): void {
// Reap any child that finished, non-blocking
while (($pid = pcntl_waitpid(-1, $status, WNOHANG)) > 0) {
$childPids = array_filter($childPids, fn (int $p): bool => $p !== $pid);
fwrite(STDERR, "Child {$pid} reaped via SIGCHLD handler\n");
}
});
while (!$shuttingDown || count($childPids) > 0) {
usleep(100_000);
}
echo "All children terminated, parent exiting cleanly\n";
6. Inter-process communication: what pcntl does NOT solve
A key difference from thread based process parallelization in other languages: PHP child processes do not automatically share memory. pcntl itself provides no built in way to exchange data between parent and child process once the fork has happened. Anyone who wants results back from child processes needs an explicit communication channel.
Common options for this communication within process parallelization: named pipes or Unix domain sockets for fast, bidirectional data exchange, a shared file or database table for simple cases, or the shmop or sysvshm extensions for true shared memory between processes. For most use cases where child processes need to report results back, a database table or a message queue such as Redis is the most pragmatic path, since it usually already exists and requires no additional IPC logic.
An alternative, often simpler approach: child processes simply write their results to their own file or their own database record, identified by the task ID, and the parent process reads these results after the pcntl_wait call. This avoids complex IPC mechanisms entirely and uses infrastructure that already exists in most PHP projects.
7. Memory isolation: copy-on-write and shared resources
The copy-on-write principle makes pcntl_fork surprisingly cheap, even though the entire process memory appears to be duplicated. The kernel initially shares the same physical memory pages between parent and child process and only copies a page once either process actually writes to it. For process parallelization this means a fork right after loading large, immutable data structures such as configuration data or lookup tables is practically free, as long as those structures are only read in the child process.
Things look different for resources that are not plain memory areas. File descriptors, database connections and network sockets are duplicated too, but afterward they point to the same underlying kernel objects. If the parent process writes to a file after the fork whose descriptor the child also owns, both see the same file pointer, causing interference. The safe practice in process parallelization: either close resources that should not be shared before the fork, or reopen them only after the fork.
OPcache deserves special attention here: compiled bytecode is also shared via copy-on-write, meaning child processes benefit from the parent process's already warmed up OPcache without having to recompile themselves. This makes process parallelization with many short lived child processes in PHP considerably cheaper than one might assume at first glance.
8. Designing proper error handling in child processes
An unhandled exception in a child process can easily go unnoticed in process parallelization, because by default the parent process only sees the numeric exit code, not the actual error message. Every child process should therefore wrap its entire work in its own try catch block, log errors explicitly, for example via error_log with a prefix containing the process ID, and exit with a clearly defined, non-zero exit code.
A detail often overlooked: if a child process dies via a signal instead of a regular exit call, for example because the operating system killed it through the out of memory killer, pcntl_wifexited returns false. The parent process in process parallelization must therefore check both pcntl_wifexited and pcntl_wifsignaled to distinguish between normal termination, error termination and external termination.
For batch processing, a retry mechanism at the parent process level is also recommended: if a task fails inside a child process, it is moved to a retry list and attempted again with a new child process, instead of aborting the entire process parallelization at the first failure. This resilience is especially important for tasks touching external resources such as APIs or network file systems, which occasionally fail transiently.
9. pcntl compared to Fibers, Swoole and queue workers
Process parallelization with pcntl is only one of several tools for concurrent processing in PHP. The choice largely depends on whether the work is CPU bound or I/O bound, and whether true parallel execution across multiple cores is needed or cooperative concurrency in a single thread suffices.
| Tool | True parallelism | Suited for | Limitation |
|---|---|---|---|
| pcntl_fork | Yes, multiple cores | CPU-bound batch jobs | Unix only, no shared memory |
| Fibers | No, one thread | I/O-bound concurrency | No CPU speed benefit |
| Swoole coroutines | Yes, with worker processes | High throughput servers | Extension, own runtime environment |
| ext-parallel | Yes, true threads | Isolated CPU calculations | Strict data isolation between threads |
| Queue workers (multiple PHP processes) | Yes, via operating system | Distributed, long-term jobs | Additional infrastructure required |
For many projects a simple pcntl_fork based script is sufficient, especially for one off batch processing or CLI tools running on a single server. Once processing needs to scale beyond a single server, a message queue with several independent PHP FPM or CLI worker processes is usually the more robust, if more elaborate, solution, because it allows horizontal scaling across multiple machines, something process parallelization with pcntl within a single process tree naturally cannot achieve.
Mironsoft
CLI automation and batch processing with PHP
Batch jobs that actually use every core?
We design robust worker pools using pcntl, including clean signal handling, zombie prevention and retry logic, so your batch processing reliably exhausts every available CPU core.
Worker pool design
Fixed process count, supervisor logic and orderly shutdown
Signal handling
SIGTERM, SIGCHLD and orderly forwarding to child processes
Monitoring
Detecting zombie processes, retry strategies and per-process logging
10. Summary
Process parallelization with pcntl is the right tool when PHP code needs to run truly CPU-bound work concurrently across multiple cores, a goal neither Fibers nor ReactPHP can reach. pcntl_fork creates real operating system processes with isolated memory via copy-on-write, pcntl_wait and pcntl_waitpid prevent zombie processes, and signal handling with pcntl_async_signals enables a clean, orderly shutdown of the entire worker pool.
The biggest pitfalls in process parallelization lie in shared resources that cannot actually be shared, in forgotten zombie cleanup, and in missing signal handling for production operation. Anyone who consistently addresses these three points gets, with plain PHP and no additional extensions, a robust mechanism for parallel batch processing that fits seamlessly into existing CLI scripts and cron jobs.
Process Parallelization with pcntl — The Essentials at a Glance
Creating a fork
pcntl_fork() returns the child PID in the parent, 0 in the child, minus 1 on error. Open database connections only afterward.
Avoiding zombies
pcntl_waitpid with WNOHANG in a non-blocking loop reliably reaps finished children.
Signal handling
pcntl_async_signals(true) plus SIGTERM/SIGCHLD handlers enable an orderly shutdown.
Know the limits
Unix only, no automatically shared memory, size the process count to the core count.