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
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
$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();Семантика фабрики та знищення
Фабрика виконується ліниво в робочому потоці, що отримує слот. Пул із 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. |
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 за задумом працює лише через замикання: він свідомо ніколи не повертає 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 — потрібні сторінки він прочитає сам.
Використай OxPHP Shared Pool у моєму коді замість того, щоб створювати зʼєднання з базою даних, HTTP-клієнт чи парсер заново на кожен запит. Прочитай сторінку `shared-state/shared-pool` через MCP-сервер документації OxPHP.
Знайди в цьому репозиторії таке створення на кожен запит і покажи мені заміну на Pool разом із `with()`, з розміром, розрахованим на кількість робочих потоків, з якою справді працює це розгортання.Мій пул OxPHP насичений, і запити завершуються тайм-аутом під час отримання слота. Прочитай сторінку `shared-state/shared-pool` через MCP-сервер документації OxPHP.
Забери `/__ox_shared/entry` і `/metrics` цього пулу з мого запущеного розгортання та скажи мені, спираючись на `waiting`, `idle_by_thread` і лічильники результатів отримання, чи потрібен мені більший `maxSize`, чи повільна сама фабрика.Повʼязані сторінки
- Спільний стан — огляд і ментальна модель.
- Shared\Once — коли потрібен рівно один ресурс (а не пул із N).
- Shared\Channel — поєднуйте з пулом для конвеєрів «виробник/споживач».
- Shared\Map — окремий
Poolдля кожного тенанта з ключем за іменем. - Режим воркера — дескриптори пулу між запитами в межах одного робочого потоку.