Description
Description
On a busy php-fpm server, an application that calls opcache_reset() on many requests makes every newly spawned worker start bigger and bigger, without limit, until FPM is restarted.
The memory is a single large zero-filled anonymous mapping allocated by zend_map_ptr_extend() when a worker loads its first script from SHM. It is not visible to userland: on the affected production server PHP reported peak request memory of 2-38 MB while each worker's RSS was ~1.1 GB from birth.
Each opcache_reset() permanently increases ZCSG(map_ptr_last); it never shrinks. Workers born later allocate (and zero) the whole table.
Concurrency is required to reproduce. With resets happening while the pool is idle, growth is negligible (500 resets => +2 MB). With concurrent traffic and workers being spawned/reaped, the table grows on every reset.
I have not pinned down the exact code path for the concurrent case. My reading is that accel_activate() resets ZCSG(map_ptr_last) via zend_reset_cache_vars() only in the process that performs the restart, while workers that were busy keep their old (large) CG(map_ptr_last) and write it back to SHM on the next cache_script_in_shared_memory() (zend_persist.c: ZCSG(map_ptr_last) = CG(map_ptr_last);). That part is a hypothesis; the measurements below are not.
Real-world impact
A PHP application called opcache_reset() on ~4,700 requests/day (~1 every 16 s) on a shared server with ~850 req/min of traffic:
- growth measured in production: ~0.3 MB per reset (app of ~6,000 cached scripts)
- 21 hours after the last FPM restart, every newly spawned worker was ~960 MB at birth
- php-fpm total RSS: 52 GB across ~50 workers; a graceful reload brought it back to 2.4 GB
- the previous day the box (128 GB RAM) hit OOM, the kernel started killing processes and sites went down
- OPcache SHM is shared by all pools of that PHP version, so unrelated accounts on the same server were inflated too
Reproducer
Attached script: starts its own php-fpm (temp dir, own socket), serves a small app (2,500 autoloaded classes with inheritance/interfaces/traits) and sends concurrent FastCGI requests, optionally issuing opcache_reset() concurrently. Python 3.6+, stdlib only.
python3 php_opcache_reset_repro.py <php-fpm> <opcache.so|-> 600 noreset
python3 php_opcache_reset_repro.py <php-fpm> <opcache.so|-> 600 reset
It prints, before and after, the max worker RSS and the max anonymous mapping in a worker (where the map_ptr table lives).
Results (600 cycles, 6 concurrent requests each)
| PHP |
Build |
mode |
max worker RSS |
max anon mapping |
| 8.3.33 |
Debian/Sury |
noreset |
20 -> 22 MB |
4 -> 2 MB |
| 8.4.24 |
Debian 13 |
noreset |
21 -> 22 MB |
6 -> 2 MB |
| 8.5.8 |
static-php-cli |
noreset |
42 -> 44 MB |
19 -> 19 MB |
| 8.1.34 |
cPanel ea-php |
reset (598) |
19 -> 87 MB |
4 -> 57 MB |
| 8.1.34 |
CloudLinux alt-php |
reset (598) |
20 -> 85 MB |
4 -> 54 MB |
| 8.3.33 |
Debian/Sury |
reset (592) |
20 -> 94 MB |
4 -> 65 MB |
| 8.3.33 |
cPanel ea-php |
reset (598) |
19 -> 84 MB |
6 -> 55 MB |
| 8.4.24 |
Debian 13 |
reset (600) |
20 -> 97 MB |
6 -> 66 MB |
| 8.4.25 |
cPanel ea-php |
reset (600) |
20 -> 89 MB |
6 -> 58 MB |
| 8.4.25 |
CloudLinux alt-php |
reset (595) |
21 -> 87 MB |
6 -> 55 MB |
| 8.5.8 |
static-php-cli |
reset (597) |
43 -> 111 MB |
19 -> 61 MB |
Growth is linear and does not level off: 1,800 resets give ~200 MB (8.3.33) and ~206 MB (8.5.8) of anonymous mapping per worker.
Note this is distinct from GH-8646, whose fix (zend_map_ptr_reset() at request end when CG(interned_strings) is non-empty) only applies when OPcache is disabled. Here OPcache is enabled and the growth happens through opcache_reset().
Expected result
Repeated opcache_reset() should not permanently increase per-worker memory. After a reset the map_ptr table should return to a size proportional to the code actually cached.
Actual result
ZCSG(map_ptr_last) grows on every reset and never shrinks, so every worker spawned afterwards allocates and zero-fills an ever larger table, until php-fpm is restarted/reloaded.
PHP Version
Reproduced on 8.1.34, 8.3.33, 8.4.24, 8.4.25 and 8.5.8 (Debian, Sury, static-php-cli, cPanel ea-php, CloudLinux alt-php). The relevant code in ext/opcache/ZendAccelerator.c looks unchanged in master.
Operating System
AlmaLinux 10 (production), Debian 13 (local repro)
Description
Description
On a busy php-fpm server, an application that calls
opcache_reset()on many requests makes every newly spawned worker start bigger and bigger, without limit, until FPM is restarted.The memory is a single large zero-filled anonymous mapping allocated by
zend_map_ptr_extend()when a worker loads its first script from SHM. It is not visible to userland: on the affected production server PHP reported peak request memory of 2-38 MB while each worker's RSS was ~1.1 GB from birth.Each
opcache_reset()permanently increasesZCSG(map_ptr_last); it never shrinks. Workers born later allocate (and zero) the whole table.Concurrency is required to reproduce. With resets happening while the pool is idle, growth is negligible (500 resets => +2 MB). With concurrent traffic and workers being spawned/reaped, the table grows on every reset.
I have not pinned down the exact code path for the concurrent case. My reading is that
accel_activate()resetsZCSG(map_ptr_last)viazend_reset_cache_vars()only in the process that performs the restart, while workers that were busy keep their old (large)CG(map_ptr_last)and write it back to SHM on the nextcache_script_in_shared_memory()(zend_persist.c:ZCSG(map_ptr_last) = CG(map_ptr_last);). That part is a hypothesis; the measurements below are not.Real-world impact
A PHP application called
opcache_reset()on ~4,700 requests/day (~1 every 16 s) on a shared server with ~850 req/min of traffic:Reproducer
Attached script: starts its own php-fpm (temp dir, own socket), serves a small app (2,500 autoloaded classes with inheritance/interfaces/traits) and sends concurrent FastCGI requests, optionally issuing
opcache_reset()concurrently. Python 3.6+, stdlib only.It prints, before and after, the max worker RSS and the max anonymous mapping in a worker (where the map_ptr table lives).
Results (600 cycles, 6 concurrent requests each)
Growth is linear and does not level off: 1,800 resets give ~200 MB (8.3.33) and ~206 MB (8.5.8) of anonymous mapping per worker.
Note this is distinct from GH-8646, whose fix (
zend_map_ptr_reset()at request end whenCG(interned_strings)is non-empty) only applies when OPcache is disabled. Here OPcache is enabled and the growth happens throughopcache_reset().Expected result
Repeated
opcache_reset()should not permanently increase per-worker memory. After a reset the map_ptr table should return to a size proportional to the code actually cached.Actual result
ZCSG(map_ptr_last)grows on every reset and never shrinks, so every worker spawned afterwards allocates and zero-fills an ever larger table, until php-fpm is restarted/reloaded.PHP Version
Operating System
AlmaLinux 10 (production), Debian 13 (local repro)