Shared\Pool
OxPHP\Shared\Pool — это ограниченный пул ресурсов, привязанных к потоку. Это примитив для управления объектами, которые дороги в создании, не могут быть дёшево пересозданы и не должны существовать в неограниченном количестве — обычно это соединения с базой данных, кэши подготовленных выражений, переиспользуемые декодеры JSON или сессии HTTP-клиентов.
Пул выделяет каждому потоку воркера PHP собственную дорожку готовых к использованию ресурсов, обеспечивает строгий максимум по всему пулу и автоматически освобождает простаивающие слоты, так что вы не платите за неиспользуемую ёмкость.
Обзор
- Строгий бюджет.
maxSize— это жёсткий предел по всему пулу. Когда пул насыщен, захват либо ждёт, либо возвращаетnull, либо выбрасывает исключение — в зависимости от того, какой метод захвата вы вызвали. - Привязка к потоку. У каждого потока воркера есть собственная очередь простоя. Захват сначала берёт из локальной очереди и никогда не передаёт слот, созданный в потоке A, потоку B (v1).
- Фабрика выполняется в захватывающем воркере. Ресурсы создаются лениво по первому требованию в каждом потоке, а не при конструировании пула.
- Callback destroy при вытеснении. Необязательное замыкание
destroy($resource)выполняется, когда пул сбрасывает слот (таймаут простоя, ручное вытеснение, остановка сервера). - Вытеснение по таймауту простоя. Слоты, простаивающие дольше
idleTimeoutMs, уничтожаются фоновой задачей. ЗадайтеidleTimeoutMs: 0, чтобы полностью отключить вытеснение по простою. - RAII-хэндлы.
acquire()возвращаетHandle; слот автоматически возвращается в пул, когда хэндл выходит из области видимости (в том числе при исключении), или раньше — через$handle->release(). - Shareable. Пулы переживают границы запросов и разделяются по хэндлу (
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 | Scope-guard: захватить (бесконечно), выполнить $body($resource) с сырым ресурсом, освободить даже при исключении. Возвращаемое значение замыкания пробрасывается наружу. |
withTimeout |
mixed | Как with, но захват ограничен $ms. |
stats |
Pool\Stats |
Моментальный снимок счётчиков пула. |
evict |
int | Принудительно вытеснить все простаивающие слоты сейчас (независимо от idleTimeoutMs); возвращает число сброшенных. |
id |
int | Идентификатор в реестре; полезен для логирования / наблюдаемости. |
Handle::get |
mixed | Базовый ресурс. Выбрасывает 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() — это паттерн с самым коротким временем жизни: захват происходит на входе, освобождение — при возврате или при исключении — утечь хэндлом невозможно. Кроме того, он передаёт вашему замыканию сырой ресурс напрямую, так что шаг Handle::get() можно пропустить.
Ручной захват / освобождение
<?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));Неблокирующий захват с запасным вариантом
<?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.Ограниченный захват с таймаутом
<?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();Семантика factory и destroy
Фабрика выполняется лениво в захватывающем потоке воркера. Пул с maxSize: 32 не выделяет 32 ресурса заранее; он создаёт их по мере поступления спроса, ограничиваясь maxSize по всем потокам вместе взятым.
- Фабрика должна возвращать объект PHP. Возврат не-объекта проявляется как
TypeExceptionиз вызова захвата, и слот не засчитывается в бюджет. - Фабрика, которая выбрасывает исключение, пробрасывает своё исключение вызывающему захват без изменений, и слот не засчитывается в бюджет.
- Callback destroy (если он задан) выполняется, когда пул сбрасывает слот: истечение таймаута простоя, явный
evict()или остановка сервера. Он выполняется в потоке воркера (а не в потоке Tokio, управляющем планировщиком вытеснения), поэтому вызывать PHP безопасно. - Callback destroy, который выбрасывает исключение, логируется, но не портит пул — слот уже уничтожается, поэтому откатывать нечего.
Привязка к потоку
Пулы v1 строго привязаны к потоку: слот, созданный в потоке воркера A, нельзя захватить в потоке воркера B. На практике это означает, что stats()->idle() может быть ненулевым на воркере A, пока воркер B блокируется в acquire(). Так слоты остаются «горячими» в том потоке, который их использует (соединения с БД, прогретые OPcache объекты), и ресурсы не перекидываются между ядрами.
Межпоточный захват работы (work stealing) — кандидат на v1.x. До тех пор подбирайте maxSize исходя из числа потоков воркеров × ожидаемая конкурентность на поток, а не только из совокупного спроса.
Вытеснение по таймауту простоя
Простаивающие слоты вытесняются фоновым планировщиком. Когда слот простаивает дольше idleTimeoutMs, планировщик помечает его; владеющий им воркер уничтожает его при следующем запросе (при живом движке PHP, так что $destroy выполняется в обычном контексте запроса). Бюджет освобождается в тот же момент.
Настраивайте idleTimeoutMs под стоимость пересоздания:
- Дёшево пересоздать (декодер JSON, пул строк): задайте 10_000–60_000 мс; быстро освобождайте память, когда трафик стихает.
- Дорого пересоздать (соединение с БД, сессия TLS): задайте от 300_000 мс (по умолчанию) до 900_000 мс; реже платите за пересоздание.
- Никогда не вытеснять: передайте
0. Тогда простаивающие слоты живут, пока пул не будет уничтожен.
$pool->evict() принудительно вытесняет все простаивающие слоты, доступные вызывающему воркеру прямо сейчас — независимо от idleTimeoutMs — и возвращает число сброшенных. Это операционный аварийный выход «сбросить простой сейчас» (например, нижестоящий сервис перезапустился, и вы хотите, чтобы следующий захват создал свежие ресурсы). Слоты в использовании не затрагиваются.
Семантика бюджета и захвата
Каждый вариант захвата сначала пытается удовлетворить запрос немедленно — переиспользовать простаивающий слот или (если пул ниже maxSize) создать новый через фабрику. Поведение различается только тогда, когда пул насыщен — нет простаивающего слота и достигнут maxSize:
| Состояние на момент вызова | acquire() |
tryAcquire() |
acquireTimeout($ms) |
|---|---|---|---|
| Простаивающий слот в очереди локального потока | переиспользуется немедленно | переиспользуется немедленно | переиспользуется немедленно |
Нет простаивающего слота, но ниже maxSize |
фабрика создаёт слот | фабрика создаёт слот | фабрика создаёт слот |
Насыщен (достигнут maxSize, все в использовании) |
ждёт бесконечно | возвращает null |
ждёт до $ms, затем OperationTimeoutException |
$ms должен быть > 0; 0 или отрицательное значение вызывает TypeException. Формы с дробными секундами и аргумента-маркера «бесконечности» намеренно нет — для неограниченного ожидания используйте голый acquire().
Исключения
| Исключение | Выбрасывается при |
|---|---|
OperationTimeoutException |
acquireTimeout / withTimeout превысил $ms без свободного слота. Расширяет Async\AsyncException, а не SharedException. |
TypeException |
Неположительный maxSize, отрицательный idleTimeoutMs, $ms <= 0 или фабрика, вернувшая не-объект. |
StaleHandleException |
Handle::get() после того, как хэндл был освобождён. |
UninitializedException |
Вызов метода на обёртке пула, не завершившей __construct. |
tryAcquire() не выбрасывает исключение при насыщении — он возвращает null. Поскольку OperationTimeoutException расширяет OxPHP\Async\AsyncException (а не SharedException), catch (SharedException) не поймает таймаут захвата; используйте catch (OxPHP\Async\AsyncException) или ловите OperationTimeoutException напрямую.
Оба вызова — неблокирующие try*, но Pool возвращает null при конкуренции, тогда как Mutex выбрасывает ContentionException. Это разделение структурное, а не стилистическое. Pool ориентирован на хэндл: каждый захват возвращает Handle, поэтому у исхода «насыщено» есть естественный носитель — ?Handle, где null означает «нет слота» и никогда не совпадает с реальным значением (Handle сам по себе никогда не бывает пользовательским значением). Mutex по замыслу работает только через замыкание — он намеренно никогда не отдаёт объект-защитник блокировки (lock guard) в PHP, чтобы удерживаемая блокировка не могла утечь за пределы замыкания. Из-за этого tryWithLock нечего вернуть в виде nullable-объекта, а собственный результат замыкания типа mixed может законно быть null — поэтому null не может заодно означать «не захвачено». Не имея ни хэндла, ни свободного маркера, единственный однозначный сигнал конкуренции — исключение. Ловите соответственно: tryAcquire → проверяйте на null; tryWithLock → catch (ContentionException).
Исключения, выброшенные внутри фабрики, пробрасываются вызывающему захват без изменений и не расходуют бюджет. Исключения внутри тела with() / withTimeout() пробрасываются вызывающему после того, как слот освобождён.
Наблюдаемость
Полный обзор см. в Наблюдаемость разделяемого состояния. Краткая справка:
GET /__ox_shared/entry?id=Nотдаёт{ type: "Pool", size, in_use, idle, waiting, max_size, idle_by_thread, rebalance_strategy }.GET /__ox_shared/summaryвключает разделPoolсcount,bytesиops. Показатели по отдельным пулам, такие какwaiting, и счётчикevicted_totalдоступны на/metrics(ниже), а не агрегируются в сводке.- Метрики Prometheus по каждому пулу:
oxphp_shared_pool_size{pool_id="…"}— gauge, всего слотов (в использовании + простаивающие).oxphp_shared_pool_in_use{pool_id="…"}— gauge.oxphp_shared_pool_idle{pool_id="…"}— gauge.oxphp_shared_pool_waiting{pool_id="…"}— gauge, захваты в очереди.oxphp_shared_pool_acquire_total{pool_id="…",result="ok|timeout|closed|saturated"}— counter.saturatedсчитает неблокирующие вызовыtryAcquire, заставшие пул заполненным (в отличие отtimeout, который означает истёкшее ожидание).oxphp_shared_pool_evicted_total{pool_id="…",reason="idle_timeout|evict|shutdown"}— counter.oxphp_shared_pool_wait_seconds_*{pool_id="…"}— гистограмма ожидания захвата (bucket / sum / count).
Комбинации, достойные алерта: растущий waiting при плоском size означает, что пул насыщен и его нужно увеличить; растущий acquire_total{result="timeout"} при нормальном in_use означает, что фабрика медленная (или блокирует); растущий acquire_total{result="saturated"} означает, что вызывающие постоянно попадают в tryAcquire на заполненном пуле (сработало противодавление (backpressure)).
Когда не стоит использовать
- Дешёвые или неизменяемые ресурсы. Накладные расходы пула больше, чем пересоздание простого объекта. Используйте его для ресурсов, создание которых стоит миллисекунд или килобайтов.
- Объекты, которые нельзя безопасно переиспользовать. Если ресурс накапливает состояние на уровне запроса (открытые транзакции, незавершённые чтения) и вы не можете надёжно его сбросить, пулинг приводит к утечке состояния между запросами. Приводите слоты в известное состояние в коде завершения запроса — или не используйте пул.
- Ресурсы между хостами. Пул работает в пределах процесса. Для пулинга соединений между хостами предпочтите сервис-пул соединений или sidecar (pgbouncer, proxy-sql).
- Неограниченное разветвление (fan-out). Если вам нужно по одному соединению на каждый активный HTTP-вызов, это не пул — это проблема «N на запрос». Вместо этого используйте
Shared\Channel, чтобы сериализовать работу за ограниченным пулом. - Ресурсы с собственной семантикой пулинга. Многие клиентские библиотеки уже пулят внутри себя (например, пул соединений Guzzle). Ставить
Shared\Poolповерх — двойной учёт; предпочтите собственный пулинг библиотеки.
Связанное
- Разделяемое состояние — обзор и ментальная модель.
- Shared\Once — когда нужен ровно один ресурс (а не пул из N).
- Shared\Channel — объедините с пулом для конвейеров производитель/потребитель.
- Shared\Map — один
Poolна арендатора с ключом по имени. - Режим воркеров — пул сохраняет слоты между запросами в пределах одного потока воркера.