Shared\Pool

OxPHP\Shared\Pool は、スレッドごとのリソースを境界付けしたプールです。作成コストが高く、安価に再作成できず、無制限に存在すべきではないオブジェクト(典型的にはデータベース接続、プリペアドステートメントのキャッシュ、再利用可能な JSON デコーダー、HTTP クライアントセッションなど)を管理するためのプリミティブです。

Pool は各 PHP ワーカースレッドに、すぐ使えるリソースの専用レーンを与え、プール全体にわたる厳格な最大値を強制し、アイドルなスロットを自動的にリサイクルするため、使っていないキャパシティに対して支払う必要がありません。

概要

  • 厳格な予算。 maxSize はプール全体にわたるハードキャップです。プールが飽和すると、acquire は待機するか、null を返すか、例外をスローします。どの acquire メソッドを呼ぶかによって挙動が決まります。
  • スレッドごとのアフィニティ。 各ワーカースレッドは独自のアイドルキューを持ちます。acquire はまずローカルキューから取り出し、スレッド A で作られたスロットをスレッド B に引き渡すことは決してありません(v1)。
  • ファクトリは acquire するワーカー上で実行されます。 リソースはプール構築時ではなく、スレッドごとに最初に要求されたときに遅延生成されます。
  • 退避時の destroy コールバック。 オプションの destroy($resource) クロージャは、プールがスロットを破棄するとき(アイドルタイムアウト、手動退避、サーバー停止)に実行されます。
  • アイドルタイムアウトによる退避。 idleTimeoutMs より長くアイドル状態が続いたスロットは、バックグラウンドタスクによって破棄されます。idleTimeoutMs: 0 を設定すると、アイドル退避を完全に無効化できます。
  • RAII ハンドル。 acquire()Handle を返します。ハンドルがスコープ外になると(例外時も含む)、スロットは自動的にプールに返却されます。$handle->release() によってそれより早く返却することもできます。
  • 共有可能。 プールはリクエストの境界を超えて存続し、ハンドルによって共有されます(クロージャ内で use ($pool))。

API リファレンス

php
namespace OxPHP\Shared; final class Pool implements Shareable { public function __construct( callable $factory, // fn(): object — create a resource ?callable $destroy = null, // fn(object): void — tear down a resource int $maxSize = 32, // hard cap on live slots; > 0 int $idleTimeoutMs = 300_000, // idle ms before eviction; 0 disables it ); // acquire family — millisecond timeout trichotomy public function acquire(): Pool\Handle; // wait forever public function tryAcquire(): ?Pool\Handle; // non-blocking; null if saturated public function acquireTimeout(int $ms): Pool\Handle; // bounded; $ms > 0 // with family — scope-guard around the raw resource public function with(callable $body): mixed; // wait forever public function withTimeout(callable $body, int $ms): mixed; // bounded; $ms > 0 public function stats(): Pool\Stats; // point-in-time snapshot of counters public function evict(): int; // force-evict all idle slots now; returns count public function id(): int; } namespace OxPHP\Shared\Pool; class Handle { public function get(): mixed; // the underlying resource (throws after release) public function release(): void; // return the slot now; idempotent; also runs on destruct } final class Stats { public function inUse(): int; // slots currently checked out public function idle(): int; // free slots ready to hand out public function waiting(): int; // callers blocked in acquire public function size(): int; // inUse() + idle() (live slots) public function maxSize(): int; // configured cap public function utilization(): float; // inUse() / maxSize(), 0.0 if maxSize() == 0 }
メソッド 戻り値 ユースケース
acquire Handle リソースをチェックアウトし、空きスロットができるまで永久に待機します。
tryAcquire ?Handle ノンブロッキングなチェックアウト。プールが飽和している場合はただちに null を返します。
acquireTimeout Handle $ms> 0)で境界付けされた予算内でチェックアウトします。期限切れ時は OperationTimeoutException をスローします。
with mixed スコープガード。acquire(永久待機)し、生リソースを渡して $body($resource) を実行し、例外時でも release します。クロージャの戻り値はそのまま返されます。
withTimeout mixed with と同様ですが、acquire が $ms で境界付けされます。
stats Pool\Stats プールのカウンターの一時点でのスナップショット。
evict int すべてのアイドルスロットを今すぐ強制退避します(idleTimeoutMs に関係なく)。破棄した数を返します。
id int レジストリ識別子。ロギングや可観測性に役立ちます。
Handle::get mixed 基盤となるリソース。release 後は StaleHandleException をスローします。
Handle::release void スロットを今すぐプールに返却します。冪等であり、デストラクト時にも自動的に実行されます(RAII)。

タイムアウトは Shared\MutexShared\Channel と同じ三分法に従います。素のメソッドは永久に待機し、try* メソッドはノンブロッキング、*Timeout(int $ms) メソッドは境界付けされたミリ秒数だけ待機します。浮動小数点秒のタイムアウトはありません。

使用例

データベース接続プール

php
<?php $db = new OxPHP\Shared\Pool( factory: function () { return new PDO( getenv('DB_DSN'), getenv('DB_USER'), getenv('DB_PASS'), [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION], ); }, destroy: function (PDO $conn) { // Nothing to do — PDO closes on destruct. The callback exists // for resources that need explicit teardown (sockets, handles). }, maxSize: 16, idleTimeoutMs: 60_000, // free idle connections after 1 min ); // In a request handler $users = $db->with(function (PDO $conn) use ($userId) { $stmt = $conn->prepare('SELECT * FROM users WHERE id = ?'); $stmt->execute([$userId]); return $stmt->fetch(); });

with() は最も寿命の短いパターンです。acquire は入場時に、release は return 時 または 例外時に発生するため、ハンドルをリークさせることはできません。また、クロージャに生リソースを直接渡すため、Handle::get() のステップを省けます。

手動での acquire / release

php
<?php $h = $pool->acquire(); // waits forever for a free slot $conn = $h->get(); $conn->beginTransaction(); doWork($conn); $conn->commit(); $h->release(); // or just let $h fall out of scope (RAII)

ハンドルは破棄されたとき(例外がスタックを巻き戻す場合も含む)に自動的にスロットを返却するため、明示的な release() はオプションです。手動ハンドルを(with() の代わりに)使うのは、リソースがハンドラーのシーケンス内の複数の呼び出しをまたいで存続しなければならない場合だけにしてください。

再利用可能なパーサープール

php
<?php $parsers = new OxPHP\Shared\Pool( factory: fn () => new JsonMachine\Parser(), maxSize: 8, ); $doc = $parsers->with(fn ($p) => $p->parse($body));

フォールバック付きのノンブロッキング acquire

php
<?php $h = $pool->tryAcquire(); if ($h === null) { // Pool saturated — degrade gracefully without waiting. http_response_code(503); header('Retry-After: 1'); return; } // ... use $h->get(); slot returns on scope exit.

タイムアウト付きの境界付き acquire

php
<?php try { $h = $pool->acquireTimeout(100); // wait up to 100 ms } catch (OxPHP\Shared\OperationTimeoutException $e) { http_response_code(503); header('Retry-After: 1'); return; } // ... use $h->get();

ファクトリと destroy のセマンティクス

ファクトリは acquire するワーカースレッド上で遅延実行されます。maxSize: 32 のプールは 32 個のリソースを事前確保しません。全スレッドを合わせて maxSize で境界付けしつつ、需要が来たときにリソースを生成します。

  • ファクトリは PHP オブジェクトを返さなければなりません。オブジェクト以外を返すと、acquire 呼び出しから TypeException として表面化し、そのスロットは予算にカウントされません。
  • 例外をスローするファクトリは、その例外をそのまま acquire の呼び出し元に伝播させ、そのスロットは予算にカウントされません。
  • destroy コールバック(指定されている場合)は、プールがスロットを破棄するとき(アイドルタイムアウトの期限切れ、明示的な evict()、サーバーシャットダウン)に実行されます。これはワーカースレッド上(退避スケジューラを駆動する Tokio スレッド上ではない)で実行されるため、PHP を安全に呼び出せます。
  • 例外をスローする destroy コールバックはログに記録されますが、プールを汚染することはありません。スロットはすでに破棄されつつあるため、ロールバックすべき有用なものは何もありません。

スレッドごとのアフィニティ

v1 のプールは厳格にスレッドごとです。ワーカースレッド A で作られたスロットは、ワーカースレッド B で acquire できません。実際上これは、ワーカー B が acquire() でブロックしている間に、ワーカー A では stats()->idle() が非ゼロになりうることを意味します。これにより、スロットは使用するスレッド内でホットに保たれ(DB 接続、OPcache でプライムされたオブジェクトなど)、コアをまたいでリソースをシャッフルすることを避けられます。

スレッドごとのアフィニティ下でのサイジング

スレッド間のワークスティーリングは v1.x の候補です。それまでは、maxSize を集約需要だけでなく、ワーカースレッド数 × スレッドごとに想定される並行処理数に対してサイジングしてください。

アイドルタイムアウトによる退避

アイドルなスロットは、バックグラウンドスケジューラによって退避されます。スロットが idleTimeoutMs より長くアイドル状態になると、スケジューラはそれにフラグを立てます。所有するワーカーが次のリクエストでそれを破棄します(PHP エンジンが生きているため、$destroy は通常のリクエストコンテキストで実行されます)。予算も同じ時点で解放されます。

idleTimeoutMs は再作成のコストに合わせて調整してください。

  • 再生成が安価(JSON デコーダー、文字列プール): 10_000〜60_000 ms に設定し、トラフィックが落ち着いたら素早くメモリを解放します。
  • 再生成が高価(DB 接続、TLS セッション): 300_000 ms(デフォルト)〜900_000 ms に設定し、再作成コストを支払う頻度を減らします。
  • 決して退避しない: 0 を渡します。アイドルなスロットは、プールが破棄されるまで存続します。

$pool->evict() は、呼び出したワーカーから今すぐ到達可能なすべてのアイドルスロットを(idleTimeoutMs に関係なく)強制退避し、破棄した数を返します。これは運用上の「今すぐアイドルをフラッシュする」ためのエスケープハッチです(例: ダウンストリームのサービスが再起動し、次の acquire で新しいリソースを生成させたい場合)。使用中のスロットには手を触れません。

予算と acquire のセマンティクス

すべての acquire バリアントは、まずリクエストをただちに満たそうとします。アイドルなスロットを再利用するか、(プールが maxSize を下回っていれば)ファクトリ経由で新しいスロットを生成します。プールが飽和しているとき(アイドルなスロットがなく、かつ maxSize に達しているとき)に限って、挙動が変わります。

呼び出し時の状態 acquire() tryAcquire() acquireTimeout($ms)
ローカルスレッドのキューにアイドルスロットあり ただちに再利用 ただちに再利用 ただちに再利用
アイドルスロットはないが、maxSize を下回る ファクトリがスロットを生成 ファクトリがスロットを生成 ファクトリがスロットを生成
飽和(maxSize に達し、すべて使用中) 永久に待機 null を返す $ms まで待機し、その後 OperationTimeoutException
浮動小数点秒なし、無限センチネルなし

$ms> 0 でなければなりません。0 または負の値は TypeException を発生させます。浮動小数点秒の形式や「無限」を表すセンチネル引数は意図的に用意されていません。境界のない待機には素の acquire() を使ってください。

例外

例外 発生元
OperationTimeoutException acquireTimeout / withTimeout が空きスロットのないまま $ms を超過。SharedException ではなく Async\AsyncException を継承します。
TypeException 非正の maxSize、負の idleTimeoutMs$ms <= 0、またはオブジェクト以外を返したファクトリ。
StaleHandleException ハンドルが release された後の Handle::get()
UninitializedException __construct を完了していないプールラッパーへのメソッド呼び出し。
acquire タイムアウトは SharedException ではありません

tryAcquire() は飽和時にスローしません。null を返します。OperationTimeoutException は(SharedException ではなく)OxPHP\Async\AsyncException を継承するため、catch (SharedException) では acquire タイムアウトを捕捉できませんcatch (OxPHP\Async\AsyncException) を使うか、OperationTimeoutException を直接捕捉してください。

なぜこれが Mutex::tryWithLock() と異なるのか

どちらもノンブロッキングな try* 呼び出しですが、Pool は競合時に null を返す一方、MutexContentionException をスローします。この違いは構造的なものであり、スタイルの問題ではありません。Poolハンドル優先です。あらゆる acquire が Handle を返すため、「飽和」という結果には自然な担い手があります。すなわち ?Handle であり、そこで null は「スロットなし」を意味し、実際の値と衝突することは決してありません(Handle それ自体がユーザー値になることはないため)。一方 Mutex は設計上クロージャ専用です。ロックガードを PHP に決して渡さないことで、保持されたロックがクロージャの外にリークできないようにしています。そのため tryWithLock には nullable として返すオブジェクトがなく、クロージャ自身の mixed の結果は正当に null になりうるため、null を「取得できなかった」の意味に兼用できません。ハンドルもなく空きのセンチネルもない以上、残された唯一の曖昧さのない競合シグナルは例外です。それに応じて捕捉してください。tryAcquirenull をテスト、tryWithLockcatch (ContentionException)

ファクトリ内でスローされた例外は、そのまま acquire の呼び出し元に伝播し、予算を消費しません。with() / withTimeout() のボディ内の例外は、スロットが release された後に呼び出し元へ伝播します。

可観測性

完全な解説は Shared Observability を参照してください。クイックリファレンス:

  • GET /__ox_shared/entry?id=N{ type: "Pool", size, in_use, idle, waiting, max_size, idle_by_thread, rebalance_strategy } を公開します。
  • GET /__ox_shared/summarycountbytesops を持つ Pool バケットを含みます。waiting のようなプールごとのゲージや evicted_total カウンターは(後述の)/metrics で公開され、サマリーには集約されません。
  • プールごとの Prometheus メトリクス:
    • oxphp_shared_pool_size{pool_id="…"} — ゲージ、総スロット数(使用中 + アイドル)。
    • oxphp_shared_pool_in_use{pool_id="…"} — ゲージ。
    • oxphp_shared_pool_idle{pool_id="…"} — ゲージ。
    • oxphp_shared_pool_waiting{pool_id="…"} — ゲージ、キューに入った acquire。
    • oxphp_shared_pool_acquire_total{pool_id="…",result="ok|timeout|closed|saturated"} — カウンター。saturated はプールが満杯であることを検出したノンブロッキングな tryAcquire 呼び出しを数えます(待機が経過したことを意味する timeout とは区別されます)。
    • oxphp_shared_pool_evicted_total{pool_id="…",reason="idle_timeout|evict|shutdown"} — カウンター。
    • oxphp_shared_pool_wait_seconds_*{pool_id="…"} — acquire 待機のヒストグラム(bucket / sum / count)。

アラートに値する組み合わせ: size が横ばいのまま waiting が上昇している場合、プールが飽和しておりリサイズすべきことを意味します。in_use が正常なまま acquire_total{result="timeout"} が上昇している場合、ファクトリが遅い(またはブロックしている)ことを意味します。acquire_total{result="saturated"} が上昇している場合、呼び出し元が満杯のプールに対して tryAcquire を叩き続けていること(バックプレッシャーが作動していること)を意味します。

使うべきでない場合

  • 安価または不変なリソース。 プールのオーバーヘッドは、単純なオブジェクトを再作成するよりも大きくなります。作成にミリ秒やキロバイトを要するリソースに使ってください。
  • 安全に再利用できないオブジェクト。 リソースがリクエストごとの状態(開いたトランザクション、保留中の読み取り)を蓄積し、それを確実にリセットできない場合、プーリングはリクエスト間で状態をリークさせます。リクエスト終了処理でスロットを既知の状態に戻すか、プールしないでください。
  • ホストをまたぐリソース。 プールはプロセス内です。マルチホストの接続プーリングには、接続バケットサービスやサイドカー(pgbouncer、proxy-sql)を選んでください。
  • 境界のないファンアウト。 進行中の HTTP 呼び出しごとに 1 接続が必要な場合、それはプールではありません。それはリクエストごとに N 個という問題です。代わりに Shared\Channel を使い、境界付けされたプールの背後で作業を直列化してください。
  • 独自のプールセマンティクスを持つリソース。 多くのクライアントライブラリはすでに内部でプールしています(例: Guzzle の接続プール)。その上に Shared\Pool を重ねるのは二重の帳簿付けです。ライブラリ自身のプーリングを選んでください。

関連

  • Shared State — 概要とメンタルモデル。
  • Shared\Once — N 個のプールではなく、ちょうど 1 個のリソースが必要な場合。
  • Shared\Channel — プロデューサー/コンシューマーのパイプラインでプールと組み合わせます。
  • Shared\Map — 名前でキー付けしたテナントごとに 1 つの Pool
  • Worker Mode — 1 つのワーカースレッド内でリクエストをまたいでプールハンドルを扱います。