php / php/php-src

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

Ouverte
#21,772 0 commentaires 0 réactions 0 personnes assignées Voir sur GitHub

Personne n'a encore pris cette issue.

Langage dominant
C
Étoiles
40.4k
Forks
8.2k
Merge moyen
2 j 13 h
PR mergées (30 j)
96

Description

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)

Guide de contribution

Ouvrir le guide de contribution

Par où commencer

  1. Lisez l'issue en entier, puis le guide de contribution du projet.
  2. Signalez en commentaire que vous la prenez — cela évite que deux personnes fassent le même travail.
  3. Forkez le dépôt et travaillez sur une branche.
  4. Ouvrez une pull request qui référence le numéro de l'issue.

Piste de recherche

Commencez par la paire SHM_PROTECT/SHM_UNPROTECT dans ZendAccelerator.c, aux lignes 2714–2778, puis examinez la fenêtre JIT dans zend_jit_trace.c autour de la ligne 7512 et le point du crash à la ligne 227. Reproduisez le problème avec un build ZTS en utilisant le script TrueAsync fourni et les options de tracing-JIT. Le travail est considéré comme terminé lorsque le test concurrent n'atteint plus SIGSEGV et qu'une couverture de régression appropriée a été identifiée.

Rédigé par le modèle d'indexation à partir du texte de l'issue.

Évaluation

Stack technique
c, php
Domaine
backend, performance
Type d'issue
Bug
Difficulté
4/5
Temps estimé
3-5 jours
Activité
Calme
Clarté
Plutôt claire
Accessibilité débutants
38/100

Recevez les nouvelles issues par e-mail

Un résumé court des issues GitHub adaptées aux débutants.