[php-src] Issue #21772: SHM_PROTECT/SHM_UNPROTECT race in opcache under ZTS with multiple threads and protect_memory=1
[email protected] (EdmondDantes)
| Newsgroups | php.bugs |
|---|---|
| Message-ID | <[email protected]> |
Issue: https://github.com/php/php-src/issues/21772
Author: EdmondDantes
### Description
When multiple PHP threads run concurrently in the same process (ZTS build), opcache's `SHM_PROTECT()`/`SHM_UNPROTECT()` calls race with each other because `mprotect()` is process-global, but there is no coordination (refcounting) between threads.
**I'm not 100% sure this is a bug vs. a known limitation.** But the behavior is surprising and I wanted to report it for discussion.
#### What happens
`SHM_PROTECT()` calls `mprotect(shm, PROT_READ)` and `SHM_UNPROTECT()` calls `mprotect(shm, PROT_READ|PROT_WRITE)`. These are process-wide — they affect all threads.
In `ZEND_RINIT_FUNCTION(zend_accelerator)` (ZendAccelerator.c, lines 2714–2778), every `php_request_startup()` executes an `SHM_UNPROTECT()` / `SHM_PROTECT()` pair. In a multi-threaded ZTS process, each thread calls `php_request_startup()` independently.
When tracing JIT is enabled and compiling a hot trace, it also holds SHM unprotected (zend_jit_trace.c, line 7512). If a second thread's RINIT calls `SHM_PROTECT()` while the first thread is still writing to SHM inside JIT compilation, the first thread gets SIGSEGV (write to read-only page).
Timeline:
```
Main thread Worker thread
─────────── ─────────────
zend_jit_compile_root_trace:
zend_shared_alloc_lock()
SHM_UNPROTECT() ← PROT_READ|WRITE
writing to zend_jit_traces[N]... php_request_startup():
... RINIT(accel):
... SHM_UNPROTECT() ← no-op
... ...checks restart_pending...
... SHM_PROTECT() ← mprotect(PROT_READ)
... done, returns
t->code_start = start → SIGSEGV
(page is now read-only)
```
The `zend_shared_alloc_lock()` serializes JIT compilation between threads, but it does NOT prevent RINIT's `SHM_PROTECT()` from running concurrently — RINIT doesn't acquire that lock for its `SHM_UNPROTECT/PROTECT` pair.
#### How to reproduce
Any ZTS setup with multiple threads running PHP code concurrently + `opcache.protect_memory=1` + `opcache.jit=tracing`.
Minimal reproduction with [TrueAsync](https://github.com/true-async/php-async) threads (but should be reproducible with any ZTS threading extension — parallel, pmmpthread, etc.):
```php
<?php
use Async\ThreadPool;
use function Async\spawn;
use function Async\await;
spawn(function() {
$pool = new ThreadPool(2);
$future = $pool->submit(fn() => 42);
echo await($future) . "\n";
$pool->close();
echo "Done\n";
});
```
Run with:
```
php -d opcache.enable_cli=1 \
-d opcache.jit=tracing \
-d opcache.jit_buffer_size=64M \
-d opcache.protect_memory=1 \
-d opcache.jit_hot_loop=1 \
-d opcache.jit_hot_func=1 \
test.php
```
Result: correct output `42\nDone` followed by SIGSEGV in `zend_jit_trace_add_code` (zend_jit_trace.c:227).
The crash does NOT happen with:
- `opcache.protect_memory=0` (default) — `mprotect` is never called, no race
- `opcache.jit=function` — functions are compiled before threads start, no runtime compilation during concurrent execution
#### Notes
- The `krakjoe/parallel` extension has the same architecture (calls `php_request_startup()` per thread) and avoids this by only testing with `opcache.jit=function` or `opcache.jit=disable` in CI. Their ASAN+JIT job explicitly uses `-d opcache.jit=function`.
- `run-tests.php` hardcodes `opcache.protect_memory=1` (line 300), so any multi-threaded test suite using `run-tests.php` with tracing JIT will hit this.
- The underlying issue is that `mprotect()` is process-global but `SHM_UNPROTECT/PROTECT` pairs are not coordinated across threads — there is no refcount to prevent one thread's `PROTECT` from overriding another thread's active `UNPROTECT` window.
### PHP Version
```
PHP 8.6.0-dev (cli) (ZTS DEBUG)
Zend Engine v4.6.0-dev
with Zend OPcache v8.6.0-dev
```
### Operating System
Ubuntu 24.04 (Linux 6.17)