skip to content

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%

answer

  1. queued first, called later
  2. async signals are off by default
  3. pcntl_async_signals(true) checks between opcodes
  4. pcntl_signal_dispatch() once per loop
  5. declare(ticks=1) is the legacy route

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.

solid answer

~40 s

`pcntl_signal(SIGTERM, $handler)` installs a small C-level handler that only records the signal in a queue, because running PHP code from inside a real signal handler is unsafe. The PHP `$handler` runs later, when the queue is dispatched. Out of the box nothing dispatches it: asynchronous signal handling is off, so a script without dispatch receives the signal and simply never calls the handler. Three ways fix it: `pcntl_async_signals(true)`, which makes the engine run pending handlers between opcodes; calling `pcntl_signal_dispatch()` yourself at safe points, such as once per loop iteration; or the legacy `declare(ticks=1)`. Handlers still wait while a blocking C call is running, and SIGKILL and SIGSTOP can never be caught.

code

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

pcntl_async_signals(true); // without this (or pcntl_signal_dispatch()), the handler never runs

$stop = false;
pcntl_signal(SIGTERM, function (int $signo, mixed $siginfo) use (&$stop): void {
    $stop = true;
});

while (!$stop) {
    // one unit of work
    sleep(1); // a signal cuts sleep() short
}

fwrite(STDERR, 'stopped cleanly' . PHP_EOL);
exit(0);

go deeper

for a junior

Recall that registering a handler is not enough: turn on pcntl_async_signals(true) or call pcntl_signal_dispatch() in the loop, and remember SIGKILL cannot be caught.

for a middle

Explain the two layers: a C handler that queues the signal and a later dispatch that calls the PHP callable, plus the three dispatch mechanisms and their costs.

for a senior

Show that you know what delays a handler in production — blocking extension calls and restarted system calls — and design loops with short timeouts so shutdown stays prompt.

for a principal

Judge whether in-process signal handling is the right contract for your workers, or whether the supervisor's stop timeout and idempotent jobs should carry correctness instead.

## Two layers of handler When a Unix process receives a signal, the kernel interrupts it and runs a signal handler function at an arbitrary point — in the middle of a memory allocation, a hash table update or a system call. Only a small set of operations is safe there, so the PHP engine cannot run a PHP callback directly from that moment. The pcntl extension therefore splits handling in two: 1. **The C-level handler.** `pcntl_signal()` registers one internal handler with the operating system. When a signal arrives, that handler only takes a pre-allocated record, stores the signal number (and the `siginfo` data where the platform has it) and appends it to a pending queue. 2. **The PHP-level callback.** Your callable runs later, when something **dispatches** the queue. Dispatch calls `$handler(int $signo, mixed $siginfo)` for every pending signal and then empties the queue. If nothing ever dispatches, signals pile up in the queue and the PHP callback never runs. The default action of the signal is also no longer in force, because a handler is installed — so a SIGTERM sent to such a script appears to be silently ignored. ## Three ways to dispatch | Mechanism | How it works | Cost and caveats | |---|---|---| | `pcntl_async_signals(true)` | the C handler raises the engine's interrupt flag; the VM notices it between opcodes and dispatches | very low overhead; recommended for new code | | `pcntl_signal_dispatch()` | you call it explicitly, typically once per loop iteration | handlers run only where you call it; predictable, but easy to forget | | `declare(ticks=1)` | a tick function dispatches after every tickable statement | the legacy mechanism, with measurable overhead | `pcntl_async_signals()` is both a getter and a setter. Called with no argument (or `null`) it returns whether asynchronous handling is on; called with a `bool` it changes the setting and returns the **previous** value. In the pinned source the flag starts as `0`, i.e. off, for every script. ## What still delays a handler Asynchronous dispatch runs handlers between opcodes, not inside C functions. So: - a long blocking call inside an extension — waiting on a socket read, a database query, or a blocking pop from a queue server — finishes first, and only then does the handler run; - with `restart_syscalls` left at its default `true`, the kernel restarts many interrupted system calls, so such a wait can simply continue; - `sleep()` is an exception worth knowing: a signal cuts it short and it returns the number of seconds that were left, so a sleeping worker reacts almost immediately. The practical rule is to keep blocking waits short (use timeouts) in any loop that must react to signals. ## Rules `pcntl_signal()` enforces - The handler is a `callable`, or one of the integers `SIG_IGN` (ignore) or `SIG_DFL` (restore the default action). Any other integer throws a `ValueError`; a non-callable, non-integer value throws a `TypeError`. - The signal number must be at least 1 and below the platform's signal count, or a `ValueError` is thrown. - **SIGKILL and SIGSTOP cannot be caught.** The operating system refuses the registration, so `pcntl_signal()` raises an `E_WARNING` ("Error assigning signal") and returns `false`. - `restart_syscalls` defaults to `true`, except for `SIGALRM`, where it defaults to `false` unless you pass it. - When an object method is the handler, the object is kept alive until the handler is replaced or the script ends. ## Writing the handler Because dispatch happens at a safe point, the PHP callback may call any PHP function. Good handlers are still short: they set a flag, write a log line, or forward the signal to children. Long work inside a handler delays whatever the script was doing and can re-enter code that was halfway through a change. During dispatch the extension also blocks other signals and prevents Fiber switching, so a handler that runs for seconds holds everything else up. ## Checking the setup A quick self-test from the command line: - start the script, find its PID (`posix_getpid()` prints it); - send `kill -TERM <pid>` from another terminal; - if the log line from the handler never appears, dispatch is missing — add `pcntl_async_signals(true)` near the top of the script, before the loop starts.

  • What does pcntl_async_signals(true) return?
    The previous setting, as a `bool`. Called as a setter it switches asynchronous handling and reports whether it was on before the call; called with no argument or `null` it only reports the current setting. In a fresh script the previous value is `false`, because asynchronous handling starts off.
  • A handler is registered for SIGTERM with async signals on, but the worker takes 30 seconds to react. Why?
    The engine runs handlers between opcodes, so a long blocking call inside C code — a socket read, a database query, a blocking queue pop with a long timeout — has to return first. With `restart_syscalls` at its default `true`, many interrupted system calls are simply restarted. Shorten the blocking timeouts so control returns to PHP often.
  • What happens if you call pcntl_signal(SIGKILL, $handler)?
    The operating system does not allow SIGKILL (or SIGSTOP) to be caught, so the registration fails: PHP raises an `E_WARNING` "Error assigning signal" and `pcntl_signal()` returns `false`. Nothing you register can intercept SIGKILL, which is why graceful shutdown relies on SIGTERM.

saying these in an interview costs you the question

  • pcntl_signal() runs the PHP callback immediately, from inside the kernel's signal delivery
  • Asynchronous signal handling is on by default in the CLI
  • declare(ticks=1) is the only way to make handlers run
  • A handler can catch SIGKILL if it is registered early enough
  • Async signals interrupt a blocking database query in the middle