php / php/php-src

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

オープン
#21,772 コメント 0 件 リアクション 0 件 担当者 0 名 GitHub で見る

まだ誰も着手していません。

主要言語
C
スター
40.4k
フォーク
8.2k
平均マージ
2日 13時間
マージ済み PR(30日)
96

説明

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)

コントリビューションガイド

コントリビューションガイドを開く

はじめの一歩

  1. issue を最後まで読み、次にプロジェクトのコントリビューションガイドを読みます。
  2. 着手することを issue にコメントします — 二人が同じ作業をするのを防げます。
  3. リポジトリをフォークし、ブランチを切って変更します。
  4. issue 番号を参照したプルリクエストを送ります。

調査の方向性

まず ZendAccelerator.c の 2714–2778 行にある SHM_PROTECT/SHM_UNPROTECT のペアから始め、次に zend_jit_trace.c の 7512 行付近にある JIT ウィンドウと、227 行目のクラッシュ箇所を調べます。提供されている TrueAsync スクリプトと tracing-JIT オプションを使用して、ZTS ビルドで再現してください。完了とは、並行テストが SIGSEGV に到達しなくなり、適切なリグレッションカバレッジが特定されていることです。

索引モデルが issue の本文から書いたものです。

評価

技術スタック
c, php
領域
backend, performance
issue の種類
バグ
難易度
4/5
見積もり時間
3〜5日
活発さ
静か
明瞭さ
おおむね明確
初心者へのやさしさ
38/100

新しい issue をメールで受け取る

初心者向けの GitHub issue を短くまとめたダイジェスト。