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 リファレンス
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
$stats = new OxPHP\Shared\Mutex(['hits' => 0, 'bytes' => 0]);
$stats->withLock(function (array &$s) use ($responseBytes) {
$s['hits'] += 1;
$s['bytes'] += $responseBytes;
});その値を観測する別のワーカーは、単一のクリティカルセクションで両方のフィールドを読み取ります。
$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
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
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
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
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* 型に再入したりしないでください。
これは、以前の「あらゆる 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 を通じて状態を読み取ってください。
別のミューテックスを使うか、コードを再構成してください。同一スレッドでの再入は機能ではなくバグです。
ファイバーのキャンセルは Async\AsyncException として伝播します。 リクエストのキャンセルによって中断された withLock はその例外を発生させ、ロックはクリーンに解放されます。
例外
| 例外 | 親 | 発生元 |
|---|---|---|
ContentionException |
Async\AsyncException |
保持されたロックに対する tryWithLock。 |
OperationTimeoutException |
Async\AsyncException |
withLockTimeout の期限切れ。 |
DeadlockException |
Async\AsyncException |
同一スレッドでの再入、または検出された待機サイクル。 |
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) を使って構造化データを上位へ伝播させてください。
関連
- Shared State — 概要とメンタルモデル。
- Shared\Counter — ガード対象の状態が 1 つの整数の場合。
- Shared\Flag — ガード対象の状態が 1 つの bool の場合。
- Shared\Channel — 相互排他ではなく、待機 + 受け渡しが必要な場合(そして例外スタイルではなく Result スタイルの戻り値が欲しい場合)。
- Shared\Map — グローバルな競合を避けるために、キーごとに Mutex を分割します。