Skip to content

SHM_PROTECT/SHM_UNPROTECT race in opcache under ZTS with multiple threads and protect_memory=1 #21772

@EdmondDantes

Description

@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 threads (but should be reproducible with any ZTS threading extension — parallel, pmmpthread, etc.):

<?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)

Metadata

Metadata

Assignees

No one assigned

    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