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 リファレンス
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\Mutex や Shared\Channel と同じ三分法に従います。素のメソッドは永久に待機し、try* メソッドはノンブロッキング、*Timeout(int $ms) メソッドは境界付けされたミリ秒数だけ待機します。浮動小数点秒のタイムアウトはありません。
使用例
データベース接続プール
<?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
$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
$parsers = new OxPHP\Shared\Pool(
factory: fn () => new JsonMachine\Parser(),
maxSize: 8,
);
$doc = $parsers->with(fn ($p) => $p->parse($body));フォールバック付きのノンブロッキング acquire
<?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
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 を完了していないプールラッパーへのメソッド呼び出し。 |
tryAcquire() は飽和時にスローしません。null を返します。OperationTimeoutException は(SharedException ではなく)OxPHP\Async\AsyncException を継承するため、catch (SharedException) では acquire タイムアウトを捕捉できません。catch (OxPHP\Async\AsyncException) を使うか、OperationTimeoutException を直接捕捉してください。
どちらもノンブロッキングな try* 呼び出しですが、Pool は競合時に null を返す一方、Mutex は ContentionException をスローします。この違いは構造的なものであり、スタイルの問題ではありません。Pool はハンドル優先です。あらゆる acquire が Handle を返すため、「飽和」という結果には自然な担い手があります。すなわち ?Handle であり、そこで null は「スロットなし」を意味し、実際の値と衝突することは決してありません(Handle それ自体がユーザー値になることはないため)。一方 Mutex は設計上クロージャ専用です。ロックガードを PHP に決して渡さないことで、保持されたロックがクロージャの外にリークできないようにしています。そのため tryWithLock には nullable として返すオブジェクトがなく、クロージャ自身の mixed の結果は正当に null になりうるため、null を「取得できなかった」の意味に兼用できません。ハンドルもなく空きのセンチネルもない以上、残された唯一の曖昧さのない競合シグナルは例外です。それに応じて捕捉してください。tryAcquire → null をテスト、tryWithLock → catch (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/summaryはcount、bytes、opsを持つ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 つのワーカースレッド内でリクエストをまたいでプールハンドルを扱います。