Skip to content

What prefork changes for Laravel workers

php artisan queen:supervise with prefork on and two pools: the process tree, a forked worker's private memory against a spawned one's, the jobs each pool ran, a new fork server at queue:restart, and a stop that leaves no process behind.

Updated View as Markdown

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/laravel

The 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 checks

On 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:

examples/apps/laravel/app/Jobs/RecordWorker.phpphp
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:

examples/apps/laravel/config/queen.phpphp
'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:

examples/apps/laravel/app/Console/Commands/Examples/PreforkExample.phpphp
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:work with the arguments a spawned one gets, and the fork server sets its process title, so ps shows it as queue:work. It leads its own session, so the master drains and signals it directly.
  • The pool’s own prefork wins. isolated has 'prefork' => false, so the master spawns its workers through worker_launcher.php, which execs queue:work with 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=1 the 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.

Navigation

Type to search…

↑↓ navigate↵ selectEsc close