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.
maxSizeest un plafond ferme sur l'ensemble du pool. Lorsque le pool est saturé, une acquisition attend, renvoienullou 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
idleTimeoutMssont détruits par une tâche en arrière-plan. DéfinissezidleTimeoutMs: 0pour désactiver entièrement l'éviction par inactivité. - Handles RAII.
acquire()renvoie unHandle; 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
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
$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
$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
$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
$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
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
TypeExceptiondepuis 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.
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 |
$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é. |
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.
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 ; tryWithLock → catch (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=Nexpose{ type: "Pool", size, in_use, idle, waiting, max_size, idle_by_thread, rebalance_strategy }.GET /__ox_shared/summaryinclut un compartimentPoolaveccount,bytesetops. Les jauges par pool commewaitinget le compteurevicted_totalsont 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.saturatedcompte les appels non bloquantstryAcquirequi ont trouvé le pool plein (distinct detimeout, 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\Channelpour 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\Poolpar-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
Poolpar tenant, clé par nom. - Mode worker — des handles de pool à travers les requêtes au sein d'un même thread worker.