Shared\Pool

OxPHP\Shared\Pool — це обмежений пул ресурсів, привʼязаних до потоків. Це примітив для керування обʼєктами, які дорого створювати, неможливо дешево відтворити і які не повинні існувати в необмеженій кількості: зазвичай це зʼєднання з базою даних, кеші підготовлених запитів, повторно використовувані JSON-декодери або сесії HTTP-клієнтів.

Пул дає кожному робочому потоку PHP власну доріжку готових до використання ресурсів, дотримується строгого максимуму для всього пулу й автоматично звільняє слоти, що простоюють, тож ви не платите за місткість, якою не користуєтеся.

Огляд

  • Строгий бюджет. maxSize — жорстка межа для всього пулу. Коли пул насичений, отримання слота або чекає, або повертає null, або кидає виняток — залежно від того, який метод отримання ви викликаєте.
  • Привʼязка до потоку. Кожен робочий потік має власну чергу вільних слотів. Отримання спершу бере слот із локальної черги й ніколи не передає потоку B слот, створений у потоці A (v1).
  • Фабрика виконується в потоці, що отримує слот. Ресурси створюються ліниво, за першої потреби в кожному потоці, а не під час конструювання пулу.
  • Колбек знищення під час витіснення. Необовʼязкове замикання destroy($resource) виконується, коли пул позбувається слота (тайм-аут простою, ручне витіснення, зупинка сервера).
  • Витіснення за тайм-аутом простою. Слоти, що простоюють довше за idleTimeoutMs, позначає фоновий планувальник, а знищує робочий потік-власник під час свого наступного запиту. Встановіть idleTimeoutMs: 0, щоб повністю вимкнути витіснення за простоєм.
  • RAII-дескриптори. acquire() повертає Handle; слот автоматично повертається до пулу, коли дескриптор виходить з області видимості (зокрема через виняток), або раніше — через $handle->release().
  • Спільне використання. Пули переживають межі запитів і передаються за дескриптором (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 Охоронець області видимості: отримати слот (без обмеження очікування), виконати $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();

Семантика фабрики та знищення

Фабрика виконується ліниво в робочому потоці, що отримує слот. Пул із maxSize: 32 не виділяє 32 ресурси заздалегідь; він створює їх у міру появи потреби, у межах maxSize для всіх потоків разом.

  • Фабрика мусить повертати PHP-обʼєкт. Якщо вона повертає не обʼєкт, виклик отримання кидає TypeException, а слот не зараховується до бюджету.
  • Якщо фабрика кидає виняток, він без змін доходить до коду, що викликав отримання, а слот не зараховується до бюджету.
  • Фатальна помилка всередині фабрики завершує запит, і слот так само не зараховується до бюджету.
  • Колбек знищення (якщо його передано) виконується, коли пул позбувається слота: після спливу тайм-ауту простою, явного evict() або під час зупинки сервера. Він виконується в робочому потоці (а не в потоці Tokio, що керує планувальником витіснення), тож викликати PHP безпечно.
  • Колбек знищення, що кидає виняток, логується, але не псує пул: слот уже знищується, тож відкочувати немає чого.

Привʼязка до потоку

Пули 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 за задумом працює лише через замикання: він свідомо ніколи не повертає guard блокування в PHP, щоб утримуване блокування не могло вийти за межі замикання. Тож tryWithLock не має обʼєкта, який можна було б повернути як nullable, а власний результат замикання типу mixed цілком законно може бути null — отже, null не може водночас означати «не отримано». Коли немає ні дескриптора, ні вільного сентинела, єдиним однозначним сигналом конкуренції лишається виняток. Обробляйте відповідно: tryAcquire → перевірка на null; tryWithLock → catch (ContentionException).

Винятки, кинуті всередині фабрики, без змін доходять до коду, що викликав отримання, і не витрачають бюджет. Винятки всередині тіла with() / withTimeout() доходять до коду, що викликав метод, уже після звільнення слота. Фатальна помилка всередині тіла завершує запит, а слот однаково повертається до пулу — у тому стані, у якому його залишило тіло. Це те саме, що й після кинутого винятку, тільки жоден блок finally у тілі не виконався. Якщо тіло може бути перерване посеред обміну (відповідь ще не прочитана, транзакція ще відкрита), починайте кожне тіло з того, щоб повернути ресурс у відомий стан.

Спостережуваність

Повний огляд — у розділі Спостережуваність спільного стану. Коротко:

  • GET /__ox_shared/entry?id=N повертає type: "Pool" і, у type_specific, { count, 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_count{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 за незмінного count означає, що пул насичений і його розмір слід змінити; зростання acquire_total{result="timeout"} за нормального in_use означає, що фабрика повільна (або блокує); зростання acquire_total{result="saturated"} означає, що виклики tryAcquire раз у раз натрапляють на заповнений пул (спрацьовує зворотний тиск).

Коли не варто використовувати

  • Дешеві або незмінні ресурси. Накладні витрати пулу більші, ніж повторне створення простого обʼєкта. Використовуйте його для ресурсів, створення яких коштує мілісекунди чи кілобайти.
  • Обʼєкти, які не можна безпечно використовувати повторно. Якщо ресурс накопичує стан окремого запиту (відкриті транзакції, незавершені читання) і ви не можете надійно його скинути, пулінг переносить стан між запитами. Повертайте слоти до відомого стану в коді завершення запиту або не використовуйте пул.
  • Ресурси між хостами. Пул існує в межах процесу. Для пулінгу зʼєднань між кількома хостами краще підійде окремий сервіс пулу зʼєднань або sidecar (pgbouncer, proxy-sql).
  • Необмежене розгалуження. Якщо вам потрібне одне зʼєднання на кожен HTTP-виклик у процесі виконання, це не пул — це проблема «N на запит». Натомість використовуйте Shared\Channel, щоб серіалізувати роботу за обмеженим пулом.
  • Ресурси з власною семантикою пулу. Багато клієнтських бібліотек уже мають внутрішній пулінг (наприклад, пул зʼєднань Guzzle). Додавати зверху Shared\Pool — це подвійний облік; надавайте перевагу власному пулінгу бібліотеки.

Промпти для вашого агента

Ці промпти розраховані на агента з підключеним MCP-сервером документації OxPHP — потрібні сторінки він прочитає сам.

prompt
Використай OxPHP Shared Pool у моєму коді замість того, щоб створювати зʼєднання з базою даних, HTTP-клієнт чи парсер заново на кожен запит. Прочитай сторінку `shared-state/shared-pool` через MCP-сервер документації OxPHP. Знайди в цьому репозиторії таке створення на кожен запит і покажи мені заміну на Pool разом із `with()`, з розміром, розрахованим на кількість робочих потоків, з якою справді працює це розгортання.
prompt
Мій пул OxPHP насичений, і запити завершуються тайм-аутом під час отримання слота. Прочитай сторінку `shared-state/shared-pool` через MCP-сервер документації OxPHP. Забери `/__ox_shared/entry` і `/metrics` цього пулу з мого запущеного розгортання та скажи мені, спираючись на `waiting`, `idle_by_thread` і лічильники результатів отримання, чи потрібен мені більший `maxSize`, чи повільна сама фабрика.

Повʼязані сторінки

  • Спільний стан — огляд і ментальна модель.
  • Shared\Once — коли потрібен рівно один ресурс (а не пул із N).
  • Shared\Channel — поєднуйте з пулом для конвеєрів «виробник/споживач».
  • Shared\Map — окремий Pool для кожного тенанта з ключем за іменем.
  • Режим воркера — дескриптори пулу між запитами в межах одного робочого потоку.