Shared\Pool
OxPHP\Shared\Pool to ograniczona co do rozmiaru pula zasobów przypisanych do wątku. Jest to prymityw do zarządzania obiektami, których utworzenie jest kosztowne, których nie da się tanio odtworzyć i które nie powinny istnieć w nieograniczonej liczbie — zwykle są to połączenia z bazą danych, cache przygotowanych zapytań, wielokrotnie używane dekodery JSON lub sesje klienta HTTP.
Pula daje każdemu wątkowi workera PHP jego własny pas gotowych do użycia zasobów, wymusza ścisłe maksimum dla całej puli i automatycznie odzyskuje bezczynne sloty, dzięki czemu nie płacisz za pojemność, której nie wykorzystujesz.
Przegląd
- Ścisły budżet.
maxSizeto twardy limit dla całej puli. Gdy pula jest wysycona, pozyskanie zasobu albo czeka, albo zwracanull, albo rzuca wyjątek — w zależności od tego, którą metodę pozyskania wywołasz. - Powinowactwo do wątku. Każdy wątek workera ma własną kolejkę zasobów bezczynnych. Pozyskanie najpierw pobiera z lokalnej kolejki i nigdy nie przekazuje wątkowi B slotu utworzonego w wątku A (v1).
- Fabryka uruchamia się w pozyskującym workerze. Zasoby są tworzone leniwie, przy pierwszym zapotrzebowaniu w danym wątku, a nie podczas konstrukcji puli.
- Wywołanie zwrotne destroy przy eksmisji. Opcjonalna domknięcie
destroy($resource)uruchamia się, gdy pula porzuca slot (limit bezczynności, ręczna eksmisja, zatrzymanie serwera). - Eksmisja po przekroczeniu limitu bezczynności. Sloty bezczynne dłużej niż
idleTimeoutMssą niszczone przez zadanie w tle. UstawidleTimeoutMs: 0, aby całkowicie wyłączyć eksmisję bezczynnych slotów. - Uchwyty RAII.
acquire()zwracaHandle; slot wraca do puli automatycznie, gdy uchwyt wychodzi poza zasięg (również w razie wyjątku), lub wcześniej przez$handle->release(). - Współdzielenie. Pule przetrwają granice żądań i są współdzielone przez uchwyt (
use ($pool)w domknięciach).
Dokumentacja 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
}| Metoda | Zwraca | Zastosowanie |
|---|---|---|
acquire |
Handle |
Pobiera zasób, czekając w nieskończoność na wolny slot. |
tryAcquire |
?Handle |
Nieblokujące pobranie. Zwraca null natychmiast, jeśli pula jest wysycona. |
acquireTimeout |
Handle |
Pobiera w ramach ograniczonego budżetu $ms (> 0); rzuca OperationTimeoutException po jego upływie. |
with |
mixed | Ochrona zasięgu: pozyskuje (w nieskończoność), uruchamia $body($resource) z surowym zasobem, zwalnia go nawet w razie wyjątku. Wartość zwracana przez domknięcie jest przekazywana dalej. |
withTimeout |
mixed | Jak with, ale pozyskanie jest ograniczone przez $ms. |
stats |
Pool\Stats |
Migawka liczników puli w danej chwili. |
evict |
int | Wymusza eksmisję wszystkich bezczynnych slotów teraz (niezależnie od idleTimeoutMs); zwraca liczbę porzuconych. |
id |
int | Identyfikator w rejestrze; przydatny do logowania / obserwowalności. |
Handle::get |
mixed | Zasób bazowy. Rzuca StaleHandleException po zwolnieniu. |
Handle::release |
void | Zwraca slot do puli teraz. Idempotentna; uruchamia się też automatycznie przy destrukcji (RAII). |
Limity czasu podlegają tej samej trychotomii co Shared\Mutex i Shared\Channel: goła metoda czeka w nieskończoność, metoda try* jest nieblokująca, a metoda *Timeout(int $ms) czeka ograniczoną liczbę milisekund. Nie ma wariantu limitu czasu w sekundach zmiennoprzecinkowych.
Przykłady
Pula połączeń z bazą danych
<?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() to wzorzec o najkrótszym czasie życia: pozyskanie następuje na wejściu, zwolnienie przy powrocie lub w razie wyjątku — nie da się wyciec uchwytu. Przekazuje też twojemu domknięciu bezpośrednio surowy zasób, więc pomijasz krok Handle::get().
Ręczne pozyskiwanie / zwalnianie
<?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)Uchwyt zwraca swój slot automatycznie, gdy zostaje zniszczony — również jeśli wyjątek rozwija stos — więc jawne release() jest opcjonalne. Po ręczny uchwyt (zamiast with()) sięgaj tylko wtedy, gdy zasób musi przetrwać kilka wywołań w sekwencji obsługi.
Pula wielokrotnie używanych parserów
<?php
$parsers = new OxPHP\Shared\Pool(
factory: fn () => new JsonMachine\Parser(),
maxSize: 8,
);
$doc = $parsers->with(fn ($p) => $p->parse($body));Nieblokujące pozyskanie z rozwiązaniem awaryjnym
<?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.Pozyskanie ograniczone limitem czasu
<?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();Semantyka fabryki i destroy
Fabryka uruchamia się leniwie w pozyskującym wątku workera. Pula z maxSize: 32 nie prealokuje 32 zasobów; tworzy je w miarę pojawiania się zapotrzebowania, ograniczona przez maxSize łącznie dla wszystkich wątków.
- Fabryka musi zwrócić obiekt PHP. Zwrócenie czegoś, co nie jest obiektem, ujawnia się jako
TypeExceptionz wywołania pozyskania, a slot nie jest liczony do budżetu. - Fabryka, która rzuca wyjątek, propaguje swój własny wyjątek do wywołującego pozyskanie bez zmian, a slot nie jest liczony do budżetu.
- Wywołanie zwrotne destroy (jeśli podane) uruchamia się, gdy pula porzuca slot: po upływie limitu bezczynności, przy jawnym
evict()lub podczas zamknięcia serwera. Uruchamia się w wątku workera (a nie w wątku Tokio napędzającym harmonogram eksmisji), więc wywoływanie PHP jest bezpieczne. - Wywołanie zwrotne destroy, które rzuca wyjątek, zostaje zalogowane, ale nie zatruwa puli — slot i tak jest właśnie niszczony, więc nie ma nic sensownego do wycofania.
Powinowactwo do wątku
Pule v1 są ściśle przypisane do wątku: slot utworzony w wątku workera A nie może zostać pozyskany w wątku workera B. W praktyce oznacza to, że stats()->idle() może być niezerowe w workerze A, podczas gdy worker B blokuje się w acquire(). Utrzymuje to sloty rozgrzanymi w wątku, który ich używa (połączenia z bazą danych, obiekty rozgrzane w OPcache) i pozwala uniknąć przetasowywania zasobów między rdzeniami.
Kradzież zadań między wątkami to kandydat na v1.x. Do tego czasu dobieraj maxSize względem liczby wątków workerów × oczekiwana współbieżność na wątek, a nie tylko względem zagregowanego zapotrzebowania.
Eksmisja po przekroczeniu limitu bezczynności
Bezczynne sloty są eksmitowane przez harmonogram działający w tle. Gdy slot jest bezczynny dłużej niż idleTimeoutMs, harmonogram go oznacza; właściciel-worker niszczy go przy swoim następnym żądaniu (przy żywym silniku PHP, więc $destroy uruchamia się w normalnym kontekście żądania). W tym samym punkcie zwalniany jest budżet.
Dostrój idleTimeoutMs do kosztu odtworzenia:
- Tanie do ponownego utworzenia (dekoder JSON, pula łańcuchów znaków): ustaw 10_000–60_000 ms; szybko zwalniaj pamięć, gdy ruch spada.
- Kosztowne do ponownego utworzenia (połączenie z bazą danych, sesja TLS): ustaw od 300_000 ms (domyślnie) do 900_000 ms; rzadziej ponoś koszt odtworzenia.
- Nigdy nie eksmituj: przekaż
0. Bezczynne sloty żyją wtedy, dopóki pula nie zostanie porzucona.
$pool->evict() wymusza eksmisję wszystkich bezczynnych slotów osiągalnych z wywołującego workera właśnie teraz — niezależnie od idleTimeoutMs — i zwraca liczbę porzuconych. To operacyjna furtka „wyczyść bezczynne teraz” (np. usługa niżej w łańcuchu została zrestartowana i chcesz, aby następne pozyskanie utworzyło świeże zasoby). Sloty w użyciu pozostają nietknięte.
Semantyka budżetu i pozyskiwania
Każdy wariant pozyskania najpierw próbuje spełnić żądanie natychmiast — użyć ponownie bezczynnego slotu lub (jeśli pula jest poniżej maxSize) utworzyć nowy przez fabrykę. Dopiero gdy pula jest wysycona — brak bezczynnego slotu i osiągnięto maxSize — zachowanie się różni:
| Stan w chwili wywołania | acquire() |
tryAcquire() |
acquireTimeout($ms) |
|---|---|---|---|
| Bezczynny slot w kolejce lokalnego wątku | użyty ponownie natychmiast | użyty ponownie natychmiast | użyty ponownie natychmiast |
Brak bezczynnego slotu, ale poniżej maxSize |
fabryka tworzy slot | fabryka tworzy slot | fabryka tworzy slot |
Wysycona (na maxSize, wszystkie w użyciu) |
czeka w nieskończoność | zwraca null |
czeka do $ms, potem OperationTimeoutException |
$ms musi być > 0; 0 lub wartość ujemna rzuca TypeException. Celowo nie ma formy z sekundami zmiennoprzecinkowymi ani argumentu-wartownika „nieskończoność” — użyj gołego acquire() dla nieograniczonego oczekiwania.
Wyjątki
| Wyjątek | Rzucany przez |
|---|---|
OperationTimeoutException |
acquireTimeout / withTimeout przekroczyło $ms bez wolnego slotu. Rozszerza Async\AsyncException, nie SharedException. |
TypeException |
Niedodatnie maxSize, ujemne idleTimeoutMs, $ms <= 0 lub fabryka, która zwróciła coś, co nie jest obiektem. |
StaleHandleException |
Handle::get() po zwolnieniu uchwytu. |
UninitializedException |
Wywołanie metody na wrapperze puli, który nie zakończył __construct. |
tryAcquire() nie rzuca wyjątku przy wysyceniu — zwraca null. Ponieważ OperationTimeoutException rozszerza OxPHP\Async\AsyncException (a nie SharedException), catch (SharedException) nie przechwyci limitu czasu pozyskania; użyj catch (OxPHP\Async\AsyncException) lub przechwyć OperationTimeoutException bezpośrednio.
Oba to nieblokujące wywołania try*, ale Pool zwraca null przy rywalizacji, podczas gdy Mutex rzuca ContentionException. Ten podział jest strukturalny, a nie stylistyczny. Pool jest uchwytocentryczna: każde pozyskanie zwraca Handle, więc wynik „wysycona” ma naturalnego nośnika — ?Handle, gdzie null oznacza „brak slotu” i nigdy nie koliduje z rzeczywistą wartością (Handle sam nigdy nie jest wartością użytkownika). Mutex jest z założenia wyłącznie domknięciowa — celowo nigdy nie oddaje strażnika blokady do PHP, więc utrzymywana blokada nie może wyciec poza domknięcie. To sprawia, że tryWithLock nie ma obiektu, który mógłby zwrócić jako nullowalny, a własny wynik mixed domknięcia może zgodnie z prawem być null — więc null nie może pełnić podwójnej roli „nie pozyskano”. Nie mając ani uchwytu, ani wolnego wartownika, jedynym jednoznacznym sygnałem rywalizacji, jaki pozostaje, jest wyjątek. Przechwytuj odpowiednio: tryAcquire → sprawdź null; tryWithLock → catch (ContentionException).
Wyjątki rzucone wewnątrz fabryki propagują do wywołującego pozyskanie bez zmian i nie zużywają budżetu. Wyjątki wewnątrz ciała with() / withTimeout() propagują do wywołującego po zwolnieniu slotu.
Obserwowalność
Zobacz Obserwowalność stanu współdzielonego, aby zapoznać się z pełnym omówieniem. Skrócone odniesienia:
GET /__ox_shared/entry?id=Nudostępnia{ type: "Pool", size, in_use, idle, waiting, max_size, idle_by_thread, rebalance_strategy }.GET /__ox_shared/summaryzawiera koszykPoolzcount,bytesiops. Wskaźniki per-pula, takie jakwaiting, oraz licznikevicted_totalsą udostępniane na/metrics(poniżej), a nie agregowane w podsumowaniu.- Metryki Prometheus per pula:
oxphp_shared_pool_size{pool_id="…"}— miernik, łączna liczba slotów (w użyciu + bezczynne).oxphp_shared_pool_in_use{pool_id="…"}— miernik.oxphp_shared_pool_idle{pool_id="…"}— miernik.oxphp_shared_pool_waiting{pool_id="…"}— miernik, pozyskania w kolejce.oxphp_shared_pool_acquire_total{pool_id="…",result="ok|timeout|closed|saturated"}— licznik.saturatedzlicza nieblokujące wywołaniatryAcquire, które zastały pełną pulę (odrębne odtimeout, które oznacza, że upłynął czas oczekiwania).oxphp_shared_pool_evicted_total{pool_id="…",reason="idle_timeout|evict|shutdown"}— licznik.oxphp_shared_pool_wait_seconds_*{pool_id="…"}— histogram oczekiwania na pozyskanie (bucket / sum / count).
Kombinacje warte alarmu: rosnące waiting przy płaskim size oznacza, że pula jest wysycona i należy zmienić jej rozmiar; rosnące acquire_total{result="timeout"} przy normalnym in_use oznacza, że fabryka jest wolna (lub blokuje); rosnące acquire_total{result="saturated"} oznacza, że wywołujący wciąż trafiają na tryAcquire przy pełnej puli (uruchamia się przeciwciśnienie).
Kiedy nie stosować
- Tanie lub niemutowalne zasoby. Narzut puli jest większy niż ponowne utworzenie prostego obiektu. Używaj jej dla zasobów, których utworzenie kosztuje milisekundy lub kilobajty.
- Obiekty, których nie da się bezpiecznie użyć ponownie. Jeśli zasób gromadzi stan per-żądanie (otwarte transakcje, oczekujące odczyty) i nie potrafisz go niezawodnie zresetować, pulowanie przenosi stan między żądaniami. Przywracaj sloty do znanego stanu w kodzie kończącym żądanie albo nie pulowuj.
- Zasoby między hostami. Pula działa w obrębie procesu. Do pulowania połączeń między wieloma hostami wybierz usługę-koszyk połączeń lub sidecar (pgbouncer, proxy-sql).
- Nieograniczone rozgałęzianie. Jeśli potrzebujesz jednego połączenia na każde żądanie HTTP w locie, to nie jest pula — to problem typu N-na-żądanie. Zamiast tego użyj
Shared\Channel, aby zserializować pracę za ograniczoną pulą. - Zasoby z własną semantyką pulowania. Wiele bibliotek klienckich już pulowuje wewnętrznie (np. pula połączeń Guzzle). Nakładanie na to
Shared\Poolto podwójna księgowość; wybierz własne pulowanie danej biblioteki.
Powiązane
- Stan współdzielony — przegląd i model myślowy.
- Shared\Once — gdy potrzebujesz dokładnie jednego zasobu (a nie puli N).
- Shared\Channel — sparuj z pulą dla potoków producent/konsument.
- Shared\Map — jedna
Poolna najemcę, kluczowana nazwą. - Tryb worker — uchwyty puli między żądaniami w obrębie jednego wątku workera.