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. maxSize to twardy limit dla całej puli. Gdy pula jest wysycona, pozyskanie zasobu albo czeka, albo zwraca null, 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ż idleTimeoutMs są niszczone przez zadanie w tle. Ustaw idleTimeoutMs: 0, aby całkowicie wyłączyć eksmisję bezczynnych slotów.
  • Uchwyty RAII. acquire() zwraca Handle; 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

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 }
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
<?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
<?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
<?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
<?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
<?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 TypeException z 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.

Dobieranie rozmiaru przy powinowactwie do wątku

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
Bez sekund zmiennoprzecinkowych, bez wartownika nieskończoności

$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.
Limity czasu pozyskania nie są SharedException

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.

Dlaczego różni się to od Mutex::tryWithLock()

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; tryWithLockcatch (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=N udostępnia { type: "Pool", size, in_use, idle, waiting, max_size, idle_by_thread, rebalance_strategy }.
  • GET /__ox_shared/summary zawiera koszyk Pool z count, bytes i ops. Wskaźniki per-pula, takie jak waiting, oraz licznik evicted_total są 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. saturated zlicza nieblokujące wywołania tryAcquire, które zastały pełną pulę (odrębne od timeout, 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\Pool to 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 Pool na najemcę, kluczowana nazwą.
  • Tryb worker — uchwyty puli między żądaniami w obrębie jednego wątku workera.