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

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

Неблокирующий захват с запасным вариантом

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.

Ограниченный захват с таймаутом

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

Семантика 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.
Таймауты захвата — это не SharedException

tryAcquire() не выбрасывает исключение при насыщении — он возвращает null. Поскольку OperationTimeoutException расширяет OxPHP\Async\AsyncException (а не SharedException), catch (SharedException) не поймает таймаут захвата; используйте catch (OxPHP\Async\AsyncException) или ловите OperationTimeoutException напрямую.

Чем это отличается от Mutex::tryWithLock()

Оба вызова — неблокирующие try*, но Pool возвращает null при конкуренции, тогда как Mutex выбрасывает ContentionException. Это разделение структурное, а не стилистическое. Pool ориентирован на хэндл: каждый захват возвращает Handle, поэтому у исхода «насыщено» есть естественный носитель — ?Handle, где null означает «нет слота» и никогда не совпадает с реальным значением (Handle сам по себе никогда не бывает пользовательским значением). Mutex по замыслу работает только через замыкание — он намеренно никогда не отдаёт объект-защитник блокировки (lock guard) в PHP, чтобы удерживаемая блокировка не могла утечь за пределы замыкания. Из-за этого tryWithLock нечего вернуть в виде nullable-объекта, а собственный результат замыкания типа mixed может законно быть null — поэтому null не может заодно означать «не захвачено». Не имея ни хэндла, ни свободного маркера, единственный однозначный сигнал конкуренции — исключение. Ловите соответственно: tryAcquire → проверяйте на null; tryWithLockcatch (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 на арендатора с ключом по имени.
  • Режим воркеров — пул сохраняет слоты между запросами в пределах одного потока воркера.