Shared\Pool

OxPHP\Shared\Pool est un pool borné de ressources propres à chaque thread. C'est la primitive pour gérer des objets coûteux à créer, qu'on ne peut pas recréer à faible coût et qui ne doivent pas exister en nombre illimité — typiquement des connexions à la base de données, des caches de requêtes préparées, des décodeurs JSON réutilisables ou des sessions de client HTTP.

Un Pool donne à chaque thread worker PHP sa propre file de ressources prêtes à l'emploi, impose un maximum strict sur l'ensemble du pool et recycle automatiquement les emplacements inactifs pour que vous ne payiez pas une capacité que vous n'utilisez pas.

Vue d'ensemble

  • Budget strict. maxSize est un plafond ferme sur l'ensemble du pool. Lorsque le pool est saturé, une acquisition attend, renvoie null ou lève une exception — selon la méthode d'acquisition que vous appelez.
  • Affinité par thread. Chaque thread worker possède sa propre file d'inactifs. Une acquisition puise d'abord dans la file locale et ne transmet jamais à un thread B un emplacement créé sur le thread A (v1).
  • La factory s'exécute dans le worker qui acquiert. Les ressources sont créées paresseusement à la première demande de chaque thread, et non à la construction du pool.
  • Callback de destruction lors de l'éviction. Une closure optionnelle destroy($resource) s'exécute lorsque le pool abandonne un emplacement (délai d'inactivité, éviction manuelle, arrêt du serveur).
  • Éviction par délai d'inactivité. Les emplacements inactifs depuis plus longtemps que idleTimeoutMs sont détruits par une tâche en arrière-plan. Définissez idleTimeoutMs: 0 pour désactiver entièrement l'éviction par inactivité.
  • Handles RAII. acquire() renvoie un Handle ; l'emplacement retourne automatiquement au pool lorsque le handle sort de la portée (y compris en cas d'exception), ou plus tôt via $handle->release().
  • Partageable. Les pools survivent aux limites de requête et se partagent par handle (use ($pool) dans les closures).

Référence de l'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 }
Méthode Renvoie Cas d'usage
acquire Handle Emprunte une ressource, en attendant indéfiniment un emplacement libre.
tryAcquire ?Handle Emprunt non bloquant. Renvoie null immédiatement si le pool est saturé.
acquireTimeout Handle Emprunte dans une limite de temps de $ms (> 0) ; lève OperationTimeoutException à l'expiration.
with mixed Garde de portée : acquiert (indéfiniment), exécute $body($resource) avec la ressource brute, libère même en cas d'exception. La valeur de retour de la closure est transmise.
withTimeout mixed Comme with, mais l'acquisition est limitée par $ms.
stats Pool\Stats Instantané ponctuel des compteurs du pool.
evict int Force l'éviction de tous les emplacements inactifs immédiatement (indépendamment de idleTimeoutMs) ; renvoie le nombre abandonné.
id int Identifiant de registre ; utile pour la journalisation / l'observabilité.
Handle::get mixed La ressource sous-jacente. Lève StaleHandleException après libération.
Handle::release void Retourne l'emplacement au pool immédiatement. Idempotent ; s'exécute aussi automatiquement à la destruction (RAII).

Les délais d'expiration suivent la même trichotomie que Shared\Mutex et Shared\Channel : une méthode nue attend indéfiniment, une méthode try* est non bloquante et une méthode *Timeout(int $ms) attend un nombre borné de millisecondes. Il n'existe pas de délai d'expiration en secondes flottantes.

Exemples

Pool de connexions à la base de données

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() est le modèle à durée de vie la plus courte : l'acquisition a lieu à l'entrée, la libération a lieu au retour ou en cas d'exception — vous ne pouvez pas fuiter un handle. Il transmet aussi à votre closure la ressource brute directement, ce qui vous évite l'étape Handle::get().

Acquisition / libération manuelle

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)

Le handle retourne automatiquement son emplacement lorsqu'il est détruit — y compris si une exception déroule la pile — de sorte qu'un release() explicite est optionnel. N'optez pour un handle manuel (plutôt que with()) que lorsque la ressource doit survivre à plusieurs appels au sein d'une séquence de traitement.

Pool de parseurs réutilisables

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

Acquisition non bloquante avec repli

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.

Acquisition bornée avec un délai d'expiration

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();

Sémantique de la factory et de la destruction

La factory s'exécute paresseusement sur le thread worker qui acquiert. Un pool avec maxSize: 32 ne préalloue pas 32 ressources ; il les crée à mesure que la demande arrive, dans la limite de maxSize sur l'ensemble des threads combinés.

  • La factory doit renvoyer un objet PHP. Renvoyer autre chose qu'un objet se manifeste par une TypeException depuis l'appel d'acquisition, et l'emplacement n'est pas décompté du budget.
  • Une factory qui lève une exception propage sa propre exception à l'appelant de l'acquisition sans la modifier, et l'emplacement n'est pas décompté du budget.
  • Le callback de destruction (s'il est fourni) s'exécute lorsque le pool abandonne un emplacement : expiration du délai d'inactivité, evict() explicite ou arrêt du serveur. Il s'exécute sur un thread worker (et non sur le thread Tokio qui pilote le planificateur d'éviction), il est donc sûr d'appeler PHP.
  • Un callback de destruction qui lève une exception est journalisé mais n'empoisonne pas le pool — l'emplacement est déjà en cours de destruction, il n'y a donc rien d'utile à annuler.

Affinité par thread

Les pools v1 sont strictement par thread : un emplacement créé sur le thread worker A ne peut pas être acquis sur le thread worker B. Concrètement, cela signifie que stats()->idle() peut être non nul sur le worker A pendant que le worker B est bloqué dans acquire(). Cela garde les emplacements chauds dans le thread qui les utilise (connexions à la base de données, objets préchargés par OPcache) et évite de faire circuler les ressources entre les cœurs.

Dimensionnement sous affinité par thread

Le vol de travail entre threads (work stealing) est un candidat pour la v1.x. En attendant, dimensionnez maxSize en fonction du nombre de threads worker × la concurrence attendue par thread, et pas seulement de la demande agrégée.

Éviction par délai d'inactivité

Les emplacements inactifs sont évincés par un planificateur en arrière-plan. Lorsqu'un emplacement est resté inactif plus longtemps que idleTimeoutMs, le planificateur le marque ; le worker propriétaire le détruit lors de sa prochaine requête (avec le moteur PHP actif, de sorte que $destroy s'exécute dans un contexte de requête normal). Le budget est libéré au même moment.

Ajustez idleTimeoutMs en fonction du coût de recréation :

  • Peu coûteux à recréer (décodeur JSON, pool de chaînes) : réglez sur 10_000–60_000 ms ; libère la mémoire rapidement quand le trafic retombe.
  • Coûteux à recréer (connexion à la base de données, session TLS) : réglez sur 300_000 ms (par défaut) à 900_000 ms ; payez le coût de recréation moins souvent.
  • Ne jamais évincer : passez 0. Les emplacements inactifs vivent alors jusqu'à ce que le pool soit détruit.

$pool->evict() force l'éviction de tous les emplacements inactifs accessibles depuis le worker appelant à l'instant présent — indépendamment de idleTimeoutMs — et renvoie le nombre d'emplacements abandonnés. C'est la trappe de secours opérationnelle « vider les inactifs maintenant » (par exemple, un service en aval a redémarré et vous voulez que la prochaine acquisition crée des ressources fraîches). Les emplacements en cours d'utilisation ne sont pas touchés.

Budget et sémantique d'acquisition

Chaque variante d'acquisition tente d'abord de satisfaire la requête immédiatement — réutiliser un emplacement inactif ou (si le pool est en dessous de maxSize) en créer un nouveau via la factory. Ce n'est que lorsque le pool est saturé — aucun emplacement inactif et à maxSize — que le comportement diffère :

État au moment de l'appel acquire() tryAcquire() acquireTimeout($ms)
Emplacement inactif dans la file du thread local réutilisé immédiatement réutilisé immédiatement réutilisé immédiatement
Aucun emplacement inactif, mais en dessous de maxSize la factory crée un emplacement la factory crée un emplacement la factory crée un emplacement
Saturé (à maxSize, tout en usage) attend indéfiniment renvoie null attend jusqu'à $ms, puis OperationTimeoutException
Pas de secondes flottantes, pas de sentinelle infinie

$ms doit être > 0 ; 0 ou une valeur négative lève TypeException. Il n'existe délibérément aucune forme en secondes flottantes ni aucun argument sentinelle « infini » — utilisez le acquire() nu pour une attente non bornée.

Exceptions

Exception Levée par
OperationTimeoutException acquireTimeout / withTimeout ont dépassé $ms sans emplacement libre. Étend Async\AsyncException, pas SharedException.
TypeException maxSize non positif, idleTimeoutMs négatif, $ms <= 0, ou une factory qui a renvoyé autre chose qu'un objet.
StaleHandleException Handle::get() après la libération du handle.
UninitializedException Un appel de méthode sur un wrapper de pool dont le __construct n'est pas terminé.
Les délais d'expiration d'acquisition ne sont pas une SharedException

tryAcquire() ne lève pas d'exception en cas de saturation — elle renvoie null. Comme OperationTimeoutException étend OxPHP\Async\AsyncException (et non SharedException), un catch (SharedException) n'attrapera pas un délai d'expiration d'acquisition ; utilisez catch (OxPHP\Async\AsyncException) ou attrapez OperationTimeoutException directement.

Pourquoi cela diffère de Mutex::tryWithLock()

Les deux sont des appels try* non bloquants, mais Pool renvoie null en cas de contention tandis que Mutex lève ContentionException. La distinction est structurelle, pas stylistique. Pool est orienté handle : chaque acquisition renvoie un Handle, de sorte que le résultat « saturé » dispose d'un porteur naturel — ?Handle, où null signifie « aucun emplacement » et n'entre jamais en collision avec une valeur réelle (un Handle n'est jamais lui-même une valeur utilisateur). Mutex est exclusivement orienté closure par conception — il ne remet délibérément jamais de garde de verrou à PHP, afin qu'un verrou détenu ne puisse pas fuiter au-delà de la closure. Il ne reste donc à tryWithLock aucun objet à renvoyer sous forme nullable, et le résultat mixed de la closure peut légitimement être null — de sorte que null ne peut pas faire office de « non acquis ». N'ayant ni handle ni sentinelle libre, le seul signal de contention non ambigu qui reste est une exception. Attrapez en conséquence : tryAcquire → testez null ; tryWithLockcatch (ContentionException).

Les exceptions levées à l'intérieur de la factory se propagent à l'appelant de l'acquisition sans modification et ne consomment pas de budget. Les exceptions à l'intérieur du corps de with() / withTimeout() se propagent à l'appelant après la libération de l'emplacement.

Observabilité

Consultez Observabilité partagée pour le tour complet. Références rapides :

  • GET /__ox_shared/entry?id=N expose { type: "Pool", size, in_use, idle, waiting, max_size, idle_by_thread, rebalance_strategy }.
  • GET /__ox_shared/summary inclut un compartiment Pool avec count, bytes et ops. Les jauges par pool comme waiting et le compteur evicted_total sont exposés sur /metrics (ci-dessous), et non agrégés dans le résumé.
  • Métriques Prometheus par pool :
    • oxphp_shared_pool_size{pool_id="…"} — jauge, nombre total d'emplacements (en usage + inactifs).
    • oxphp_shared_pool_in_use{pool_id="…"} — jauge.
    • oxphp_shared_pool_idle{pool_id="…"} — jauge.
    • oxphp_shared_pool_waiting{pool_id="…"} — jauge, acquisitions en file d'attente.
    • oxphp_shared_pool_acquire_total{pool_id="…",result="ok|timeout|closed|saturated"} — compteur. saturated compte les appels non bloquants tryAcquire qui ont trouvé le pool plein (distinct de timeout, qui signifie qu'une attente s'est écoulée).
    • oxphp_shared_pool_evicted_total{pool_id="…",reason="idle_timeout|evict|shutdown"} — compteur.
    • oxphp_shared_pool_wait_seconds_*{pool_id="…"} — histogramme d'attente d'acquisition (bucket / sum / count).

Combinaisons dignes d'une alerte : une hausse de waiting avec un size stable signifie que le pool est saturé et devrait être redimensionné ; une hausse de acquire_total{result="timeout"} avec un in_use normal signifie que la factory est lente (ou bloquante) ; une hausse de acquire_total{result="saturated"} signifie que les appelants continuent d'atteindre tryAcquire sur un pool plein (la contre-pression se déclenche).

Quand ne pas l'utiliser

  • Ressources peu coûteuses ou immuables. Le surcoût d'un pool dépasse celui de la recréation d'un objet simple. Utilisez-le pour des ressources dont la création coûte des millisecondes ou des kilo-octets.
  • Objets qui ne peuvent pas être réutilisés en toute sécurité. Si la ressource accumule un état propre à la requête (transactions ouvertes, lectures en attente) et que vous ne pouvez pas la réinitialiser de façon fiable, la mise en pool fait fuiter l'état entre les requêtes. Ramenez les emplacements à un état connu dans le code de fin de requête, ou ne les mettez pas en pool.
  • Ressources inter-hôtes. Un pool est intra-processus. Pour la mise en pool de connexions multi-hôtes, préférez un service de regroupement de connexions ou un sidecar (pgbouncer, proxy-sql).
  • Diffusion non bornée (fan-out). Si vous avez besoin d'une connexion par appel HTTP en cours, ce n'est pas un pool — c'est un problème de N par requête. Utilisez plutôt un Shared\Channel pour sérialiser le travail derrière un pool borné.
  • Ressources dotées de leur propre sémantique de pool. De nombreuses bibliothèques clientes gèrent déjà un pool en interne (par exemple, le pool de connexions de Guzzle). Empiler un Shared\Pool par-dessus revient à faire une double comptabilité ; préférez le mécanisme de pool de la bibliothèque.

Voir aussi

  • État partagé — vue d'ensemble et modèle mental.
  • Shared\Once — lorsque vous avez besoin d'exactement une ressource (et non d'un pool de N).
  • Shared\Channel — à associer à un pool pour des pipelines producteur/consommateur.
  • Shared\Map — un Pool par tenant, clé par nom.
  • Mode worker — des handles de pool à travers les requêtes au sein d'un même thread worker.