With prefork, the supervisor boots Laravel once in a fork server and forks the workers from it. A
forked worker is the fork server’s child, and in this run on Linux it held 4.6 MiB of memory of its
own where a spawned worker held 29 MiB. A pool with 'prefork' => false keeps spawned workers
beside the forked ones.
Run it
php artisan example:prefork # in examples/apps/laravelThe command starts php artisan queen:supervise, the PHP engine, with QUEEN_SUPERVISOR_PREFORK
set for that process only. What it printed on Linux (PHP 8.3.35 in Docker, the command-line
opcache off) against a 2.0.1 node:
broker http://localhost:6632
started php artisan queen:supervise (pid 204) with prefork on
pid parent resident role
204 201 49.0 MiB master: queen:supervise
205 204 44.4 MiB fork server
206 205 36.8 MiB forked worker, pool default
207 205 36.8 MiB forked worker, pool default
211 204 46.8 MiB spawned worker, pool isolated
213 204 46.8 MiB spawned worker, pool isolated
ok: the pool `default` has 2 workers forked by the fork server
ok: the pool `isolated` has 2 workers spawned by the master
memory resident proportional private
forked worker 36.8 MiB 13.9 MiB 4.6 MiB
spawned worker 46.8 MiB 32.1 MiB 29.0 MiB
ok: a forked worker's private memory is under half its resident set
ok: a forked worker has less private memory than a spawned one
4 jobs to each pool
default ran on 206 and 207, children of 205 (the fork server)
ok: the forked workers ran the jobs of default
isolated ran on 211 and 213, children of 204 (the master)
ok: the spawned workers ran the jobs of isolated
php artisan queue:restart
pid parent resident role
204 201 49.0 MiB master: queen:supervise
218 204 44.4 MiB fork server
219 218 37.1 MiB forked worker, pool default
220 218 37.1 MiB forked worker, pool default
221 204 46.8 MiB spawned worker, pool isolated
224 204 46.8 MiB spawned worker, pool isolated
ok: queue:restart brought a new fork server, pid 218
ok: the workers forked since then are its children
ok: the old fork server, pid 205, exited with its workers
php artisan queen:supervisor terminate
the master exited after 0.8 s
ok: no process of the tree is left
PASS: 10 checksOn macOS the run passes 8 checks: the memory table and its two checks are replaced by a note,
because macOS has no /proc/<pid>/smaps_rollup to split a resident set into shared and private
memory.
- The tree. The two pools run under one master. The forked workers’ parent is the fork server; the spawned workers’ parent is the master.
- Resident is not what a worker costs. The forked worker shows 36.8 MiB resident, but 4.6 MiB of it is its own: the rest is shared with other processes, above all the fork server’s booted Laravel. The spawned worker owns 29.0 of its 46.8 MiB.
- A deploy. After
queue:restart, every worker was replaced, and the new forked workers are children of a new fork server.
The program
The job records its worker’s pid and that worker’s parent:
final class RecordWorker implements ShouldQueue
{
use Queueable;
public function handle(): void
{
// Journal::record() adds the worker's pid. The parent tells a
// forked worker (a child of the fork server) from a spawned one
// (a child of the master).
Journal::record($this->job, [
'event' => 'ran',
'ppid' => posix_getppid(),
]);
}
}The two pools, in config/queen.php:
'supervisor' => [
// Read the backlog, start missing workers and reap exited
// ones every second instead of every three.
'poll_interval' => 1,
'shutdown_grace' => 30,
// The switch for every pool: example:prefork sets it for its run.
'prefork' => filter_var(
env('QUEEN_SUPERVISOR_PREFORK', false),
FILTER_VALIDATE_BOOL,
),
'supervisors' => [
// Follows the switch: with prefork on, its workers are forked
// from the fork server.
'default' => [
'connection' => 'queen',
'queues' => [env('EXAMPLE_FORKED_QUEUE', 'default')],
'balance' => 'simple',
'processes' => 2,
'timeout' => 20,
// The connection long-polls: an idle worker waits on the
// broker instead of sleeping.
'sleep' => 0,
],
// Its own prefork wins over the switch (PHP client 2.3.0): its
// workers are spawned, and each boots Laravel on its own.
'isolated' => [
'connection' => 'queen',
'queues' => [env('EXAMPLE_SPAWNED_QUEUE', 'isolated')],
'balance' => 'simple',
'processes' => 2,
'timeout' => 20,
'sleep' => 0,
'prefork' => false,
],
],
],The command starts the supervisor, reads the process tree (from /proc on Linux, from ps on
macOS), and checks each step:
final class PreforkExample extends ExampleCommand
{
protected $signature = 'example:prefork';
protected $description = 'What prefork changes, under php artisan queen:supervise';
private const JOBS_PER_POOL = 4;
private int $master;
/** @var array<string, string> the queue of each pool of config/queen.php */
private array $queues;
protected function example(): void
{
// `default` follows the prefork switch; `isolated` has its own
// 'prefork' => false.
$this->queues = [
'default' => $this->freshQueue('prefork-default'),
'isolated' => $this->freshQueue('prefork-isolated'),
];
$this->master = $this->startArtisan("{$this->queues['default']}-supervisor", ['queen:supervise'], [
'QUEEN_SUPERVISOR_PREFORK' => 'true',
'EXAMPLE_FORKED_QUEUE' => $this->queues['default'],
'EXAMPLE_SPAWNED_QUEUE' => $this->queues['isolated'],
]);
$this->line("\nstarted php artisan queen:supervise (pid {$this->master}) with prefork on");
$tree = $this->waitFor('two forked and two spawned workers', 30, fn () => $this->bootedTree());
$this->printTree($tree);
$this->check(
count($tree['forked']) === 2,
'the pool `default` has 2 workers forked by the fork server',
);
$this->check(
count($tree['spawned']) === 2,
'the pool `isolated` has 2 workers spawned by the master',
);
$this->printMemory($tree);
// Jobs for both pools: each records the pid of its worker and that
// worker's parent.
$this->line("\n" . self::JOBS_PER_POOL . ' jobs to each pool');
foreach ($this->queues as $queue) {
for ($n = 0; $n < self::JOBS_PER_POOL; $n++) {
RecordWorker::dispatch()->onQueue($queue);
}
}
foreach ($this->queues as $pool => $queue) {
$ran = $this->waitForEvents($queue, 'ran', self::JOBS_PER_POOL, 30);
$pids = array_values(array_unique(array_column($ran, 'pid')));
sort($pids);
$parents = array_values(array_unique(array_column($ran, 'ppid')));
[$workers, $parent, $parentRole] = $pool === 'default'
? [$tree['forked'], $tree['forkServer'], 'the fork server']
: [$tree['spawned'], $this->master, 'the master'];
$this->line(sprintf(
' %-9s ran on %s, children of %s',
$pool,
implode(' and ', $pids),
implode(', ', $parents) . ($parents === [$parent] ? " ({$parentRole})" : ''),
));
$this->check(
array_diff($pids, array_keys($workers)) === [] && $parents === [$parent],
$pool === 'default'
? 'the forked workers ran the jobs of default'
: 'the spawned workers ran the jobs of isolated',
);
}
// A deploy: the workers stop after their job, and the master starts a
// new fork server, so that the code on disk is booted again.
$this->line("\nphp artisan queue:restart");
$this->callSilently('queue:restart');
$old = $tree;
$tree = $this->waitFor('a new fork server and its workers', 30, function () use ($old) {
$tree = $this->bootedTree();
return $tree !== null
&& $tree['forkServer'] !== $old['forkServer']
&& array_intersect_key($tree['spawned'], $old['spawned']) === []
&& !Processes::alive($old['forkServer']) ? $tree : null;
});
$this->printTree($tree);
$this->check(
$tree['forkServer'] !== $old['forkServer'],
"queue:restart brought a new fork server, pid {$tree['forkServer']}",
);
$this->check(count($tree['forked']) === 2, 'the workers forked since then are its children');
$this->check(
!Processes::alive($old['forkServer']),
"the old fork server, pid {$old['forkServer']}, exited with its workers",
);
// Stop the supervisor the way a deploy does: it drains every worker,
// then exits.
$this->line("\nphp artisan queen:supervisor terminate");
$everyPid = Processes::tree(Processes::all(), $this->master);
$this->callSilently('queen:supervisor', ['action' => 'terminate']);
$stopped = microtime(true);
$this->waitFor('the master to exit', 40, fn () => !$this->isRunning($this->master));
$this->line(sprintf(' the master exited after %.1f s', microtime(true) - $stopped));
$this->check(
array_filter($everyPid, fn (int $pid) => Processes::alive($pid)) === [],
'no process of the tree is left',
);
}
/**
* The supervisor's processes by role, once both pools run 2 workers
* that have booted (they left their mark: see AppServiceProvider);
* null until then.
*
* @return array{all: array, forkServer: int, forked: array<int, true>, spawned: array<int, true>}|null
*/
private function bootedTree(): ?array
{
$all = Processes::all();
$tree = ['all' => $all, 'forkServer' => 0, 'forked' => [], 'spawned' => []];
foreach ($all as $pid => $process) {
if ($process['ppid'] === $this->master && str_contains($process['command'], 'queen:fork-server')) {
$tree['forkServer'] = $pid;
}
}
foreach ($all as $pid => $process) {
if ($tree['forkServer'] !== 0 && $process['ppid'] === $tree['forkServer']) {
$tree['forked'][$pid] = true;
} elseif ($process['ppid'] === $this->master && str_contains($process['command'], 'queue:work')) {
$tree['spawned'][$pid] = true;
}
}
$booted = fn (array $workers, string $queue) => count($workers) >= 2
&& array_filter(
array_keys($workers),
fn (int $pid) => !is_file(Journal::readyPath($queue, $pid)),
) === [];
return $booted($tree['forked'], $this->queues['default'])
&& $booted($tree['spawned'], $this->queues['isolated']) ? $tree : null;
}
private function printTree(array $tree): void
{
$this->line("\n pid parent resident role");
$rows = [$this->master => 'master: queen:supervise', $tree['forkServer'] => 'fork server'];
foreach (array_keys($tree['forked']) as $pid) {
$rows[$pid] = 'forked worker, pool default';
}
foreach (array_keys($tree['spawned']) as $pid) {
$rows[$pid] = 'spawned worker, pool isolated';
}
foreach ($rows as $pid => $role) {
$process = $tree['all'][$pid];
$mib = $process['rss_kib'] / 1024;
$this->line(sprintf(' %-7d %-7d %5.1f MiB %s', $pid, $process['ppid'], $mib, $role));
}
}
/** Linux splits a worker's memory into what it shares and what is its own. */
private function printMemory(array $tree): void
{
$workers = [
'forked worker' => array_key_first($tree['forked']),
'spawned worker' => array_key_first($tree['spawned']),
];
if (Processes::smaps($workers['forked worker']) === null) {
$this->line("\n memory: resident sets only, this host has no /proc/<pid>/smaps_rollup");
$this->line(' to split them into shared and private; the memory checks need Linux');
return;
}
$this->line("\n memory resident proportional private");
$memory = [];
foreach ($workers as $role => $pid) {
$m = $memory[$role] = array_map(fn (int $kib) => $kib / 1024, Processes::smaps($pid));
$this->line(sprintf(
' %-15s %5.1f MiB %5.1f MiB %5.1f MiB',
$role,
$m['rss'],
$m['pss'],
$m['private'],
));
}
$forked = $memory['forked worker'];
$this->check(
$forked['private'] < $forked['rss'] / 2,
'a forked worker\'s private memory is under half its resident set',
);
$this->check(
$forked['private'] < $memory['spawned worker']['private'],
'a forked worker has less private memory than a spawned one',
);
}
}How it works
- The fork server. With prefork on for at least one pool, the master starts
php artisan queen:fork-server. It boots Laravel once, opens no connection, and forks a worker each time the master asks (prefork). - A forked worker is an ordinary worker. It runs
queue:workwith the arguments a spawned one gets, and the fork server sets its process title, sopsshows it asqueue:work. It leads its own session, so the master drains and signals it directly. - The pool’s own
preforkwins.isolatedhas'prefork' => false, so the master spawns its workers throughworker_launcher.php, whichexecsqueue:workwith the same pid (prefork for some pools only). - Why the private memory is small.
fork()shares the fork server’s booted Laravel copy-on-write; a worker copies only the pages it writes (why a forked worker is smaller). - queue:restart. A forked worker that stops for the restart signal leaves an exit marker, the master boots a new fork server, and the old one closes when its last worker is gone (how a forked worker talks to its master).
- terminate. The master sends SIGTERM to every worker, waits for them, closes the fork server and exits.
Limits
- Opcache was off. With
opcache.enable_cli=1the forked workers also share the compiled code, and the benchmark measured 1.7 MiB of private memory per worker instead of 6.2 (opcache and preload). - A job’s memory is private. The workers were measured before they ran a job. What a job allocates lands on its worker, forked or not.
- The memory check is Linux-only. On macOS the program prints the resident sets and says so.
Next
Prefork workers turns it on in production, and when not to use it lists the boots it does not suit.