Skip to content

pcntl_fork() child re-keys every inherited free list since 8.5 (slow child start, copies inherited memory) #25403

Open

Description

@alies-dev

Description

Since "Refresh zend_mm shadow key on fork" (commit c561f7d, from #16765; in 8.5, not in 8.4), a child created by pcntl_fork() runs php_child_init() → refresh_memory_manager() → zend_mm_refresh_key_child() before pcntl_fork() returns. That function draws a new shadow key, then traverses every small-bin free list inherited from the parent, validates the shadow pointer of each slot that has a successor with the old key and rewrites it with the new one. These writes trigger copy-on-write for the inherited pages they touch while those pages are still shared with the parent.

How much this costs depends on the parent's free lists. With a large, fragmented heap (a long running CLI process that created and freed many small objects) child start-up goes from about 5 ms on 8.4 to 733 ms on 8.5 in the reproducer below (2000 MiB allocation target), and most of the inherited heap becomes private to the child before it runs any code. PHP 8.3 and 8.4 do not have this cost.

The following code:

<?php
// php -d memory_limit=-1 fork.php 1000 30   (needs ext-pcntl, ext-posix)
$heap = (int) $argv[1]; $free = (int) $argv[2];
class N { public $a; public $b; function __construct($i) { $this->a = "k$i"; $this->b = [$i, $i + 1]; } }
$keep = []; $t = memory_get_usage() + $heap * 1048576;
while (memory_get_usage() < $t) { $keep[] = new N(mt_rand()); }
mt_srand(1); foreach ($keep as $i => $_) { if (mt_rand(1, 100) <= $free) $keep[$i] = null; }
gc_collect_cycles();
$out = sys_get_temp_dir() . '/fm.' . getmypid();
$t0 = hrtime(true); $pid = pcntl_fork();
if ($pid === 0) { // first statement of the child
    $t1 = hrtime(true); $ru = getrusage();
    file_put_contents($out, sprintf("%.1f ms wall, %.1f ms cpu, %d minor faults", ($t1 - $t0) / 1e6,
        ($ru['ru_utime.tv_sec'] * 1e6 + $ru['ru_utime.tv_usec'] + $ru['ru_stime.tv_sec'] * 1e6 + $ru['ru_stime.tv_usec']) / 1e3, $ru['ru_minflt']));
    posix_kill(getmypid(), SIGKILL);
}
pcntl_waitpid($pid, $st); echo file_get_contents($out), "\n";

The first argument is the allocation target in MiB (growth of memory_get_usage() before freeing), the second the percentage of N objects released afterwards, chosen at random so that the free slots are spread over the whole heap.

Resulted in this output (php -d memory_limit=-1 fork.php <MiB> 30; macOS 26.6.2 on Apple M1 Pro, 16 KB pages, Homebrew builds, one run per cell). Child start latency is from just before pcntl_fork() in the parent to the first timestamp after pcntl_fork() returns in the child; CPU time and minor faults are the child's (they include the few measurement statements; the child then kills itself so that normal shutdown is not measured):

PHP 500 MiB 1000 MiB 2000 MiB child CPU at 2000 MiB child minor faults at 2000 MiB
8.3.36 2.1 ms 3.1 ms 5.4 ms 0.3 ms 120
8.4.27 2.1 ms 3.3 ms 4.9 ms 0.4 ms 181
8.5.12 184.0 ms 378.0 ms 732.8 ms 728.4 ms 235 100

At the same 2000 MiB target with nothing released (fork.php 2000 0), all three versions take 4.6 to 5.0 ms and 119 to 194 minor faults. Retaining all objects removes almost all of the overhead, which is consistent with the free list traversal; its cost also depends on how the free slots are spread over pages.

But I expected this output instead: a child start-up cost closer to 8.4 for this workload. I understand why the walk exists; the question is whether its cost can be reduced while keeping the hardening (see the end).

Memory

Measured earlier with two variants of the same script (same heap construction, 30 % released). On Linux the child reads /proc/self/smaps_rollup as its first statement instead of calling getrusage(). On macOS the child is kept alive and footprint -p <child> is run on it 0.3 s after its first statement.

Linux (aarch64, official php:8.4-cli 8.4.26 and php:8.5-cli 8.5.7 images in a VM, 4 KB pages, pcntl added with docker-php-ext-install). 500 and 1000 MiB targets, leaving 382 and 764 MB in use after releasing; where a range is given, separate measurements differed by that much:

PHP in use after release child wall child minor faults child Rss child Private_Dirty
8.4.26 382 MB 0.6 ms 95 502 MB 0 MB
8.4.26 764 MB 2.1 ms 95 994 MB 0 MB
8.5.7 382 MB 261 to 302 ms 155 000 to 165 000 503 MB 458 MB
8.5.7 764 MB 664 to 743 ms 384 000 995 MB 916 MB

On Linux the 8.5 child's Private_Dirty is about 91 to 92 % of its Rss when its first statement runs, against 0 on 8.4.

macOS (same machine and builds as above), phys_footprint of the child, one fork per cell:

PHP allocation target parent footprint child phys_footprint
8.4.27 1000 MiB 998 MB 1.6 MB
8.4.27 2000 MiB 1982 MB 1.6 MB
8.5.12 1000 MiB 999 MB 920 MB
8.5.12 2000 MiB 1982 MB 1838 MB

On macOS the 8.5 child's phys_footprint is about 92 to 93 % of the parent's. Every child repeats the refresh; how much of this is resident at the same time depends on how many children overlap and which pages they would have dirtied themselves anyway.

Where it comes from

  • pcntl_fork(), and pcntl_rfork() and pcntl_forkx() where available, call php_child_init() in the child (pcntl_fork, pcntl_rfork, pcntl_forkx).
  • On 8.5 php_child_init() is refresh_memory_manager(), zend_max_execution_timer_init() and php_clear_stat_cache(true, NULL, 0) (main.c).
  • refresh_memory_manager() calls zend_mm_refresh_key_child() unconditionally (zend_alloc.c). That function draws a new key and then, for every bin, follows heap->free_slot[i] and calls zend_mm_set_next_free_slot() on every slot whose next is not NULL (zend_alloc.c). Of the three calls it is the only one that traverses the free lists.
  • PHP-8.6 and master still do the same eager traversal (master); there the shadow is also bound to the address it is stored at (Bind free list shadow pointers to the slot holding them #23376) and the cached chunk list is re-keyed as well (Protect the cached chunk list against corruption #23367). I found no bound, lazy variant or opt-out on either branch.

Impact

Programs that fork workers from a large, long lived parent (static analyzers, test runners, batch tools using pcntl_fork()) pay this once per worker. In Psalm, a static analyzer, run on a large private Laravel application (macOS, Apple M1, PHP 8.5.12, 8 workers), 39 forks from parents of 0.9 to 6.5 GB added up to 6.5 s of wall time in the parent (it waits for each child to connect) before the children ran any code, and the same run took 82.0 s with pcntl_fork() against 76.2 s when the forks were made with libc fork() through FFI, which skips php_child_init() (mean of 3 interleaved runs each, identical output). The FFI fork is used here only as a diagnostic comparison, see the workarounds below.

Details of the application measurements

One traced run per variant; per-stage values are medians over the stage's children, range in brackets. Footprint is macOS phys_footprint.

fork stage children parent footprint time to first statement, pcntl_fork() / libc fork() child footprint at first statement, pcntl_fork() / libc fork()
first scan round 8 0.9 GB 13 ms / 0.9 ms 49 MB / 3 MB
later scan rounds 23 2.4 to 4.3 GB 241 ms (204 to 271) / 1.1 ms 950 MB (923 to 1079) / 3 MB
analysis pool 8 6.5 GB 113 ms / 1.4 ms 490 MB / 3 MB

Summed over the 39 children of the pcntl_fork() run: 6.5 s from fork to first statement, 6.4 s of child CPU time, and 26 GB of child footprint above the libc fork() children at the same point (not all resident at once; the scan children are short lived). The median child took 119 000 minor faults (16 KB pages). With libc fork() the summed fork to first statement time was 0.13 s.

End to end, 3 interleaved runs each: 82.8, 82.1, 81.0 s with pcntl_fork() against 74.7, 75.7, 78.3 s with libc fork() (7 % less wall time, 11 % less system CPU time). The long lived workers later dirty most of their inherited pages themselves (reference counting), so at the end of the run the difference is smaller: the summed footprint peak of the process tree was 29.4 to 29.5 GB with pcntl_fork() and 27.5 to 27.9 GB with libc fork() (2 runs each).

Workarounds and their price

USE_ZEND_ALLOC=0 avoids the traversal (the Zend free lists stay empty because allocations go to the system allocator), but changes the allocator for the whole process. Calling libc fork() through FFI skips all of php_child_init():

  • Without refresh_memory_manager() the child keeps the parent's key. The inherited encodings stay consistent, but the per-child key of Refresh zend mm shadow key on fork #16765 is lost, and a debug build asserts at normal request shutdown because heap->pid was not updated (zend_alloc.c).
  • zend_max_execution_timer_init() is an empty macro unless PHP is built with ZEND_MAX_EXECUTION_TIMERS (zend_max_execution_timer.h). With it, skipping it leaves the child with the parent's timer bookkeeping for a timer that fork() does not inherit, so a later attempt to arm it can fail.
  • Without php_clear_stat_cache() the child keeps the parent's stat and realpath cache.
  • On master php_child_init() also runs the child init hooks of started extensions (Add per-extension child initialization hooks #24647), which a libc fork skips as well.

So it is not something to recommend to users.

Possible directions

Questions rather than proposals; I have not tried either. Both trade part of the hardening of #16765 for the cost: until a free list is re-keyed, it stays protected by the parent's key, which the parent and all siblings share, so that key leaked from one process would let an attacker forge links in those lists in every process of the fork family. A leaked new child key would not reveal the parent key.

  • Could the per-child key be made optional, for callers willing to accept that security trade-off, while keeping the rest of php_child_init()? Keeping the inherited key leaves the encodings valid (on master the holder addresses are the same after fork); the debug heap->pid bookkeeping would still need updating, and the key generator state would still need reseeding so that later key refreshes in siblings differ.
  • Could the key state be kept per size class, so that a free list is converted only when the child first uses it? That might avoid the copies for lists the child never touches, but every free list access path (including zend_mm_gc()) would have to handle a list still encoded with the old key, and a size class used early would still pay its whole list.

I can test a patch on both platforms above.

PHP Version

PHP 8.5.12 (cli) (built: Oct 19 2026 17:02:46) (NTS)
Copyright (c) The PHP Group
Built by Homebrew
Zend Engine v4.5.12, Copyright (c) Zend Technologies
    with Zend OPcache v8.5.12, Copyright (c), by Zend Technologies

Also reproduced with the official php:8.5-cli image (PHP 8.5.7, Linux aarch64).
The eager free list traversal is still present on PHP-8.6 and master.

Operating System

macOS 26.6.2 (Apple Silicon) and Linux aarch64 (Docker)

Activity

  1. changed the title [-]pcntl_fork() child re-encodes all inherited free lists since 8.5 (slow fork, copies inherited memory)[/-] [+]pcntl_fork() child re-keys every inherited free list since 8.5 (slow child start, copies inherited memory)[/+] on Nov 11, 2026
  2. arnaud-lb commented on Nov 12, 2026

    @arnaud-lb
    Member

    Thank you for this report. I confirm the issue.

    It's likely that the child process would dirty most of the same pages eventually, or at least the ones containing refcounted values because merely accessing those values would update refcounts. But re-keying is paying this upfront and adds additional overhead as it dirties more pages, and the freelist walk itself has a cost.

    If I understand correctly, a workaround in your case maybe to not block the parent while the child starts?

    Additional possible directions:

    • Re-key in the parent, fork, and revert in the parent. This avoids the dirtying, but we still pay for two freelist walks
    • Re-key lazily
    • Do not re-key in pcntl_fork()
    • Remove the freelist, use a bitset + cache.

    The first one only saves about 40% of the time overhead. 2 and 3 weaken the protection. 4 is slower.

    Could the per-child key be made optional, for callers willing to accept that security trade-off, while keeping the rest of php_child_init()?

    This would work indeed

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions