Shared\Mutex

OxPHP\Shared\Mutex は、ストアされた値をラップするプロセス全体の相互排他ロックです。ロックに直接触れることはありません。代わりに、3 つのメソッドバリアントのいずれかにクロージャを渡すと、ランタイムがクロージャの実行中ロックを保持し、クロージャが例外を投げた場合でもロックを解放します。

概要

  • セクションだけでなく値をガードします。 ラップされた値は参照渡しでクロージャに渡されるため、クロージャ内での直接的な変更は、クロージャが正常にリターンしたときにコミットされます。
  • 1 つの多目的な ?float $timeout ではなく、3 つの明示的な待機ポリシーを提供します。
    • withLock($fn) — ロックを取得できるまでブロックします。それ自体には時間制限がなく、待機が早く終わるのは、呼び出しが実行されているリクエストまたは非同期タスクが終了したときだけです。
    • tryWithLock($fn) — ノンブロッキング。ロックが保持されている場合は ContentionException を投げます。
    • withLockTimeout($fn, int $ms) — 制限付きの待機。期限が切れると OperationTimeoutException を投げます。
  • PHP の例外は自由に伝播します。 クロージャが通常の PHP 例外を投げた場合、ロックは解放され、例外は上位へと伝播します。ミューテックスが破損することはありません — 部分的な変更は許容されます。不変条件を復元する責任は呼び出し側にあります。
  • Rust の panic はミューテックスを破損させます。 Rust の panic が FFI 境界を越えた場合(サーバーのバグ)、ミューテックスは固着した破損状態に入り、以降のすべての取得は CorruptedMutexException を投げます。回復用の API はありません — そのインスタンスは破棄し、新しいものを作成してください。
  • デッドロックを回避します。 同じスレッド上で同じミューテックスに再入すると(このスレッド上でキャプチャされたネストされた非同期呼び出し経由を含む)、ハングする代わりに DeadlockException が発生します。

API リファレンス

php
namespace OxPHP\Shared; final class Mutex implements Shareable { public function __construct(mixed $initial = null); public function withLock(callable $fn): mixed; public function tryWithLock(callable $fn): mixed; public function withLockTimeout(callable $fn, int $ms): mixed; public function id(): int; }

クロージャのシグネチャは function (mixed &$value): mixed です — $value は参照渡しされるため、その場で変更します。クロージャの通常の戻り値は、withLock / tryWithLock / withLockTimeout の呼び出し側へ転送されます。戻り値のパスはスカラー、null、そして Shared\* インスタンスをサポートします(string、int、float、bool、バイト文字列、null、および OxPHP\Shared\Shareable を実装する任意のハンドル)。PHP の配列を返すと OxPHP\Shared\TypeException が発生します — このケースはまだサポートされておらず、別途追跡されています。構造化された配列の状態を上位へ伝播させるには、&$value をその場で変更して呼び出し後に再読み込みするか、必要なものを use (&$captured) 変数にステージングしてください。

メソッド 挙動
withLock($fn) 取得できるまでブロックし、クロージャを実行します。リクエストが続く限り待機します。
tryWithLock($fn) ノンブロッキング。保持されている場合は ContentionException を投げます。
withLockTimeout($fn, $ms) 制限付きの待機。$ms > 0 が必須。期限切れで OperationTimeoutException を投げます。
id() レジストリの識別子。ロギング / 可観測性に役立ちます。

$ms はミリ秒単位の厳密な正の整数です。ゼロ、負の値、int でない値、および値の欠如は、ブリッジで OxPHP\Shared\TypeException を発生させます — そうしたポリシーを $ms で表現しようとする代わりに、withLock(無限)または tryWithLock(ノンブロッキング)を呼び出してください。

なぜ Mutex は例外を投げるのに Channel は Result を返すのか

競合とタイムアウトは、よく設計されたミューテックスにとってはまれなイベントです(ロックは短いクリティカルセクションの間だけ保持すべきであり、持続的な競合は問題の兆候です)。それらはチャネルにとっては日常的なイベントです(ファンアウトのディスパッチャーは、忙しいサイクルごとに Full/Closed/Timeout を目にします)。そのため、

  • Mutex は例外スタイルを使います — まれなパスが例外的なものです。
  • Channel はResult スタイルを使います — 一般的なパスは throw/catch の仕組みの外に留まります。

すべての withLock を try { … } catch (ContentionException) { … } でラップしていることに気づいたなら、間違ったプリミティブを使っています。キュー型のワークロードには Shared\Channel を、単一値のアトミック性には Shared\Counter / Shared\Flag を使ってください。

同じ構造的な理由が、Pool::tryAcquire() が null を返せるのに対して Mutex::tryWithLock() は例外を投げる理由を説明します。Pool はハンドル優先です。tryAcquire(): ?Handle は「飽和状態」を null として伝え、Handle 自体がユーザー値になることはないため、曖昧さはありません。Mutex はクロージャ専用です — ロックガードを PHP に返すことを意図的に決して行わない(そのため保持されたロックがクロージャの外に漏れることはない)ので、nullable として返せるオブジェクトが残らず、しかもクロージャ自身の mixed の結果はすでに null かもしれません。空いているセンチネルがないため、競合は ContentionException として表面化します。2 つの try* の面が分岐するのは、各型が返せるものの違いによるものであり、スタイルの好みによるものではありません。

例

アトミックな複数フィールド更新

値が単一の整数であれば Counter で十分です。Mutex が力を発揮するのは、複数のフィールドを一斉に更新しなければならない場合です。

php
<?php $stats = new OxPHP\Shared\Mutex(['hits' => 0, 'bytes' => 0]); $stats->withLock(function (array &$s) use ($responseBytes) { $s['hits'] += 1; $s['bytes'] += $responseBytes; });

その値を観測する別のワーカーは、単一のクリティカルセクションで両方のフィールドを読み取ります。

php
$snapshot = ['hits' => 0, 'bytes' => 0]; $stats->withLock(function (array &$s) use (&$snapshot) { $snapshot = $s; }); // $snapshot sees both fields from the same update or neither — never the // bumped 'hits' without the matching 'bytes'. (We capture through use(&$x) // because the closure's own return is currently scalar-only — see the // closure-signature note above.)

ノンブロッキングなプローブ + デグレード

php
<?php use OxPHP\Shared\{Mutex, ContentionException}; $budget = new Mutex(['tokens' => 100, 'refill_at' => time()]); try { $budget->tryWithLock(function (array &$b) { if ($b['tokens'] <= 0) { // No tokens — leave state untouched. return; } $b['tokens'] -= 1; }); } catch (ContentionException) { // Lock held by another worker — shed the request instead of queuing. http_response_code(503); return; }

タイマー付きの取得

php
<?php use OxPHP\Shared\{Mutex, OperationTimeoutException}; $counter = new Mutex(0); try { // Return value is scalar — int $next — so the closure return is forwarded. $next = $counter->withLockTimeout(function (int &$c) { $c += 1; return $c; }, ms: 5000); } catch (OperationTimeoutException) { // Someone else held the lock longer than 5s. }

名前付き引数が推奨されます。ms: 5000 は、読み手がパラメーターの順序を覚えていなくても「5000 ミリ秒」と読めます。

すべての並行処理条件を一箇所でキャッチする

OperationTimeoutException、ContentionException、DeadlockException はすべて OxPHP\Async\AsyncException を継承しています。1 つの catch で、Shared* と Async* の両方の面にわたるあらゆる並行処理の結果を一掃できます。

php
<?php use OxPHP\Async\AsyncException; try { $state->withLockTimeout($fn, 100); } catch (AsyncException) { // timeout, contention, deadlock, or any await-related concurrency error }

破損したミューテックスからの致命的な回復

クロージャ呼び出し中の Rust の panic(サーバーのバグであり、PHP コードが行ったことではありません)は、ロックを固着した破損状態のままにします。clearPoison() に相当するものはないため、インスタンスを破棄してください。

php
<?php use OxPHP\Shared\{Mutex, CorruptedMutexException}; try { $state->withLock($fn); } catch (CorruptedMutexException) { // Old instance is dead. Recreate from the persistent source of truth. $state = new Mutex($initialState); }

セマンティクスと落とし穴

クロージャはロックを保持したまま実行されます

短く保ってください。sleep を呼び出したり、ネットワーク I/O でブロックしたり、このミューテックスにコールバックしうる他の Shared* 型に再入したりしないでください。

PHP の throw はもうロックを破損させません

これは、以前の「あらゆる throw で Poisoned」ポリシーからの意図的な変更です。部分的な変更に関するポリシーは、現在「呼び出し側が不変条件を復元する責任を負う」となっています。変更を伴わない try-compute パターンが必要な場合は、それをミューテックスの外で行い、最終的な値をコミットするためだけに withLock を呼び出してください。

ストアされる値はスカラー的なものです。 文字列、int、float、真偽値、null、およびそれらのネストされた配列が動作します。オブジェクト、クロージャ、リソースは TypeException を発生させます。

クロージャの戻り値はスカラー、null、Shared\* インスタンスをカバーしますが、配列はまだサポートされていません。 ストアされる値は依然として配列にできますが(&$value 経由で変更してください)、クロージャ自身の戻り値のパスは string/int/float/bool/null/バイト文字列、および任意の OxPHP\Shared\Shareable ハンドルを受け付けます。PHP の配列を返すと OxPHP\Shared\TypeException が発生します。配列の回避策: use (&$x) 変数にキャプチャするか、スカラーの射影を返す後続の withLock を通じて状態を読み取ってください。

同じスレッドでの再入は DeadlockException を投げます

別のミューテックスを使うか、コードを再構成してください。同一スレッドでの再入は機能ではなくバグです。

withLock の待機はリクエストの終了とともに終わります。 ロックを待機中の withLock は、max_execution_time が尽きたとき、リクエストの実行中にサーバーがドレイン期限を過ぎてシャットダウンしたとき、または呼び出しが実行されている非同期タスクが待機中にキャンセルされたときに待機をやめます。その後リクエストは、他の場所でキャンセルが起きたときと同じ形で終了します。タイムアウトの場合はコードから捕捉できない通常の 504 致命的エラーになり、キャンセルされた非同期タスクには Async\AsyncException が届きます。タイムアウト時には、ログ(display_errors が有効ならレスポンスにも)に、タイムアウトの致命的エラーの直前に警告 Uncaught OxPHP\Async\AsyncException: Mutex::withLock: wait abandoned, the request is being ended も出力されます。待機はこの例外を投げることで抜け出し、致命的エラーが発生した時点でまだ伝播中の例外を PHP が報告するためです。クライアントの切断では待機は止まりません。max_execution_time = 0 でシャットダウンもない場合、待機に終わりはないため、待機に独自の上限が必要な場面では withLockTimeout を使ってください。非同期タスク内では、max_execution_time がどんな値であっても待機は止まりません。タスクを実行するプールスレッドは実行デッドラインを持たないためです。そこで待機を終わらせるのは、タスクのキャンセル(oxphp_async_await() のタイムアウト引数)か withLockTimeout です。

クロージャ内の致命的エラーはロックを解放し、書き込みを破棄します

致命的エラー(クロージャの実行中またはスリープ中に max_execution_time が尽きた場合を含む)は、それでもリクエストを終了させます。捕捉することはできず、withLock の呼び出し以降は何も実行されません。終了の過程でロックは解放され、ミューテックスはクロージャの開始前の値を保持します。ここが致命的エラーと例外の違いです。例外を投げたクロージャは部分的な書き込みを残します(前述)が、致命的エラーで終了したクロージャは値を返すことがないため、その書き込みは一切残りません。tryWithLock と withLockTimeout にも同じことが当てはまります。

例外

例外 親 発生元
ContentionException Async\AsyncException 保持されたロックに対する tryWithLock。
OperationTimeoutException Async\AsyncException withLockTimeout の期限切れ。
DeadlockException Async\AsyncException 同一スレッドでの再入、または SHARED_LOCK_DIAGNOSTICS=strict の下で断ち切られた待機サイクル(デッドロック検出器を参照)。
CorruptedMutexException Shared\SharedException 以前のクロージャ呼び出しが Rust の panic でクラッシュしました。ミューテックスは使用不能です。
TypeException Shared\SharedException コンストラクターまたは $ms 引数が型の契約に違反しました。
StaleHandleException Shared\SharedException レジストリのエントリが退避されたハンドルに対するメソッド呼び出し。
UninitializedException Shared\SharedException __construct が完了していないラッパーに対する id()。

可観測性

Shared Observability を参照してください。クイックリファレンス:

  • GET /__ox_shared/entry?id=N は { type: "Mutex", corrupted, waiters, last_acquire_ms, held_by_thread } を公開します。
  • インスタンスごとの Prometheus メトリクス:
    • oxphp_shared_mutex_waiters{mutex_id="…"} — 現在の待機者数。
    • oxphp_shared_mutex_acquires_total{mutex_id="…"} — 累計の取得数。
    • oxphp_shared_mutex_contended_total{mutex_id="…"} — 待機を要した取得数。
    • oxphp_shared_mutex_corrupted{mutex_id="…"} — 0 / 1(_poisoned から改名)。

使うべきでない場面

  • 単一のアトミックな値。 ガード対象の値が 1 つの int または 1 つの bool であれば、Shared\Counter または Shared\Flag を使ってください — どちらもロックフリーで低コストです。
  • 長時間実行される処理。 I/O、sleep、ファイバーの await をまたいでミューテックスを保持しないでください。代わりに Shared\Channel のプロデューサー/コンシューマーパターンを使ってください。
  • 競合の激しいホットパス。 すべてのリクエストが同じミューテックスを取得しなければならない場合、スループットを直列化してしまっています。状態を分割する(例: Shared\Map<tenant_id, Mutex>)か、ワーカーごとのローカルで事前集計して定期的にフラッシュしてください。
  • ホスト間の相互排他。 プロセス内のみです。マルチホストの協調には分散ロック(Redis SET NX、etcd)を使ってください。

以前の API からの移行

かつて 現在
$m->with($fn)(無限) $m->withLock($fn)
$m->with($fn, $secs) $m->withLockTimeout($fn, $ms)、$ms はミリ秒単位
$m->tryWith($fn) → 競合時に null $m->tryWithLock($fn) → ContentionException を投げる
$m->isPoisoned() / $m->clearPoison() 削除。PHP の throw はもうミューテックスを破損させません
PoisonedException(Rust panic パス) CorruptedMutexException(公開の clear API なし)
Shared\TimeoutException Shared\OperationTimeoutException(現在は Async\AsyncException を継承)
DeadlockException extends Shared\TimeoutException DeadlockException extends Async\AsyncException

クロージャのシグネチャも function (mixed $value): mixed(return でコミット)から function (mixed &$value): mixed(参照による変更、通常の戻り値は新しい状態ではなくクロージャの値)に変更されました。クロージャが何も返さない場合、ストアされる値は参照による変更が残したものをそのまま保持します。既存の制限が 1 つ引き継がれます: クロージャの戻り値はスカラー(string / int / float / bool / null / バイト文字列)または Shared\* ハンドルでなければなりません — PHP の配列を返すと OxPHP\Shared\TypeException を投げます。ストアされる値は依然として配列にできます。&$value を通じて変更し、use (&$x) を使って構造化データを上位へ伝播させてください。

エージェント向けプロンプト

これらのプロンプトは、OxPHP ドキュメント MCP サーバー を接続したエージェントを前提としています — 必要なページはエージェントが自分で読み取ります。

prompt
2 つ以上のフィールドをまとめて変更しなければならないのに、ロックなし、あるいは `flock()` で更新している複数フィールドの状態の代わりに、私のコードで OxPHP の Shared Mutex を使ってください。OxPHP ドキュメント MCP サーバー経由で `shared-state/shared-mutex` ページを読んでください。 このリポジトリからその更新箇所を見つけ、すべてのフィールドがまとめて反映されるように、それを置き換える `withLock` のクロージャを見せてください。

関連

  • Shared State — 概要とメンタルモデル。
  • Shared\Counter — ガード対象の状態が 1 つの整数の場合。
  • Shared\Flag — ガード対象の状態が 1 つの bool の場合。
  • Shared\Channel — 相互排他ではなく、待機 + 受け渡しが必要な場合(そして例外スタイルではなく Result スタイルの戻り値が欲しい場合)。
  • Shared\Map — グローバルな競合を避けるために、キーごとに Mutex を分割します。