Stan współdzielony

OxPHP\Shared\* to zbiór współbieżnych prymitywów, które żyją wewnątrz procesu serwera i są widoczne dla każdego workera PHP. Pozwalają workerom koordynować zmienny stan (liczniki, flagi funkcji, pamięci podręczne, kolejki zadań, pule połączeń) bez sięgania po Redis, Memcached czy APCu.

Wszystko, co tu opisano, działa w całości w obrębie procesu. Stan współdzielony jest tracony po zatrzymaniu serwera. Jeśli potrzebujesz trwałości lub koordynacji między wieloma hostami, zobacz Migracja do zewnętrznego magazynu.

Dlaczego istnieje stan współdzielony

Workery PHP w tradycyjnym SAPI nie współdzielą pamięci. Każdy worker ma własne opcode'y, własne statyczne właściwości klas, własne zmienne globalne. Koordynowanie stanu między workerami tradycyjnie oznaczało zależność spoza procesu: APCu dla pamięci podręcznych na tym samym hoście, Redis dla liczników, dedykowany broker kolejek do rozsyłania (fan-out).

OxPHP uruchamia silnik PHP jako wielowątkowy SAPI działający w obrębie procesu. Oznacza to, że starannie zaprojektowany zestaw prymitywów może dać workerom bezpieczny wgląd w ten sam fragment stanu. To właśnie zapewnia OxPHP\Shared\*:

  • Dostęp bez opóźnień. Brak podróży sieciowych; brak serializacji do gniazda. Koszty pojedynczej operacji liczone są w mikrosekundach.
  • Brak zewnętrznej zależności. O jedną usługę mniej do wdrożenia, monitorowania i utrzymywania przy życiu.
  • Typowane prymitywy. Shared\Counter to atomowa liczba całkowita. Shared\Map to współbieżna mapa haszująca. Nie musisz od nowa implementować poprawności na bazie semantyki INCR.
  • Bezpieczne dla cykli i cyklu życia. Środowisko uruchomieniowe śledzi referencje, więc uchwyt nie może przeżyć swojego wpisu w rejestrze, a magazyny odrzucają grafy, które powodowałyby wyciek pamięci.

Stan współdzielony nie jest złotym środkiem. Nie zastępuje Redisa, gdy potrzebujesz trwałości między restartami, ani nie zastępuje prawdziwego brokera wiadomości, gdy potrzebujesz wielu hostów. Zakres, w którym sprawdza się najlepiej, opisano poniżej w sekcji Kiedy nie używać.

Model myślowy

Każda instancja Shared\* jest wspierana przez wpis w obejmującym cały proces rejestrze. Obiekt PHP (uchwyt, który trzymasz) niesie identyfikator w rejestrze; rejestr jest właścicielem właściwego stanu.

graph LR
  W1["PHP worker 1<br/>$counter (Shared\Counter, id=7)"] --> R
  W2["PHP worker 2<br/>$counter (Shared\Counter, id=7)"] --> R
  R["Shared registry<br/>id=7: Counter = 42<br/>id=8: Map { … }<br/>id=9: Channel(16)"]

Konsekwencje:

  • Uchwyty współdzielą stan przez referencję. Dwa workery trzymające „ten sam" licznik widzą swoje zapisy nawzajem natychmiast.
  • Czas życia podąża za referencjami. Wpis w rejestrze jest zwalniany po usunięciu ostatniego uchwytu, o ile żaden inny wpis Shared na niego nie wskazuje.
  • clone jest zabroniony. Sklonowanie uchwytu utworzyłoby dwa obiekty PHP, które wyglądają na odrębne, lecz mutują ten sam wpis w rejestrze — myląco i podatnie na błędy. Wszystkie typy rzucają wyjątek przy clone.
  • Przekazanie między wątkami jest jawne. Aby przekazać wartość Shared do działającego w tle Fibera, użyj oxphp_async(fn () use ($thing) { ... }). Import use przenosi uchwyt; sam wpis w rejestrze jest bezpieczny wątkowo.

Prymitywy v1

OxPHP 0.3 dostarcza siedem typów. Wybieraj według semantyki, a nie według tego, co wydaje się znajome:

Typ Kształt Dobre do
Shared\Counter akumulator int64 liczba żądań, zużycie per-tenant, śledzenie trafień bramek funkcji
Shared\Atomic atomowy prymityw int64 maszyny stanów, znaczniki wersji, pętle CAS, maski bitflag
Shared\Flag atomowy bool wyłączniki awaryjne, jednorazowe znaczniki inicjalizacji, stan bezpiecznika
Shared\Once kontener init-once kosztowna inicjalizacja singletona między workerami (wygrywa jedno wykonanie)
Shared\Mutex zatruwalny muteks sekcje krytyczne nad nieatomową wartością
Shared\Channel ograniczona kolejka MPMC potoki producent/konsument, rozdzielanie zadań
Shared\Map współbieżna string→mixed pamięci podręczne z kluczem, stan per-tenant, wyszukiwania w stylu rejestru
Shared\Pool ograniczona pula obiektów kosztowne zasoby per-wątek (uchwyty DB, parsery)
Sięgaj po najprostszy pasujący typ

Counter bije Map<string, int> z jednym kluczem; Flag bije Counter dla wartości true/false; Mutex<T> bije doraźne łańcuchy compare-and-set.

Szybki start: atomowy licznik w warunkach współbieżności

worker.php
<?php // worker.php — entry script in worker mode, runs once per PHP worker require __DIR__ . '/vendor/autoload.php'; // Registry::counter binds the counter under a key. Every worker that // runs this bootstrap converges on the SAME entry — one counter // process-wide. $requests = OxPHP\Shared\Registry::counter( 'request-counter', fn() => new OxPHP\Shared\Counter(), ); oxphp_worker(function () use ($requests) { $requests->add(); // atomic across every worker header('X-Request-Count: ' . $requests->get()); echo 'hello'; });

Tryb worker uruchamia zewnętrzny zakres raz na wątek workera. Przechwycenie use ($requests) utrzymuje ten sam uchwyt przy życiu przez wszystkie żądania obsługiwane przez danego workera. To Registry::counter('request-counter', …) sprawia, że wszystkie te lokalne dla workera uchwyty wskazują na jeden współdzielony wpis: fabryka wykonuje się dokładnie raz w całym procesie, a wywołanie z każdego innego workera odnajduje już powiązany wpis.

Bez Registry sam wzorzec new OxPHP\Shared\Counter() tworzy osobny licznik na każdy wątek workera (każdy bootstrap tworzy własny anonimowy wpis). To może być w porządku dla akumulatorów per-worker, ale dla sumy obejmującej cały proces przeprowadź to przez Registry.

Ten sam schemat działa w trybie tradycyjnym (bez WORKER_MODE_ENABLED). Pierwsze żądanie, które dotknie 'request-counter', tworzy wpis; każde kolejne żądanie, na dowolnym wątku workera, już go widzi. Pełną historię znajdziesz w Shared\Registry.

Gdy również Fiber musi widzieć licznik, przekaż go przez use:

php
<?php oxphp_async(function () use ($requests) { $requests->add(); // runs on whatever worker picks the fiber up });
Note

new OxPHP\Shared\Counter() w dwóch różnych miejscach tworzy dwa niezależne liczniki o różnych id(). Stan współdzielony jest współdzielony przez uchwyt (ścieżka konstruktora) lub przez nazwę poprzez Registry::counter(...). Wybierz jedno podejście i się go trzymaj; nie konstruuj na nowo w każdym workerze, oczekując, że uchwyty się połączą.

Kanoniczny przykład: migracja samodzielnie napisanego licznika

Zespoły często piszą własną koordynację na bazie APCu lub statycznych tablic. Oto wzorzec migracji do Shared\*, na przykładzie ograniczania liczby żądań per-IP jako przewodniego przykładu.

Przed: statyka + zewnętrzne blokowanie

php
<?php // Fragile: not atomic under concurrency, does not survive reloads // on some APCu builds, shares counters across unrelated hosts in a pool. final class NaiveRateLimiter { public function __construct( private int $maxRequests, private int $windowSeconds, ) {} public function allow(string $ip): bool { $key = "rl:{$ip}"; $now = time(); $current = apcu_fetch($key); if ($current === false || $now - $current['start'] >= $this->windowSeconds) { apcu_store($key, ['count' => 1, 'start' => $now], $this->windowSeconds * 2); return true; } apcu_store( $key, ['count' => $current['count'] + 1, 'start' => $current['start']], $this->windowSeconds * 2, ); return $current['count'] + 1 <= $this->maxRequests; } }

W tym fragmencie kryją się trzy błędy: para apcu_fetch + apcu_store nie jest atomowa, początek okna gubi się przy ciasnym wyścigu, a czyszczenie sprowadza się do tego, co APCu zdecyduje się usunąć po wygaśnięciu TTL.

Po: Shared\Map + pętla compareAndSet

php
<?php final class RateLimiter { public function __construct( private OxPHP\Shared\Map $buckets, private int $maxRequests, private int $windowSeconds, ) {} public function allow(string $ip): bool { $now = time(); // Atomic read-modify-write via compareAndSet. Read the current // bucket, compute the next state, and CAS it in. If a concurrent // writer raced us, retry with the new value. while (true) { $current = $this->buckets->get($ip); if ($current === null || $now - $current['start'] >= $this->windowSeconds) { $next = ['count' => 1, 'start' => $now]; } else { $next = ['count' => $current['count'] + 1, 'start' => $current['start']]; } if ($this->buckets->compareAndSet($ip, $current, $next)) { return $next['count'] <= $this->maxRequests; } // Lost the race — re-read and try again. } } /** Background cleanup — call from a scheduled worker or oxphp_async loop. */ public function sweep(): void { $now = time(); $cutoff = $this->windowSeconds * 2; $this->buckets->forEach(function (string $ip, array $state) use ($now, $cutoff): void { if ($now - $state['start'] >= $cutoff) { $this->buckets->remove($ip); } }); } } // Bootstrap — Registry::map keys the buckets under a name so every // worker and every request converges on ONE map. Without Registry the // bare `new Shared\Map(...)` here would create a separate map per // worker thread (in worker mode) or per request (in traditional mode), // and the effective rate limit would scale with the worker pool. $limiter = new RateLimiter( buckets: OxPHP\Shared\Registry::map( 'rate-limit-buckets', fn() => new OxPHP\Shared\Map(maxEntries: 50_000), ), maxRequests: 100, windowSeconds: 60, ); // Per-request if (!$limiter->allow($_SERVER['REMOTE_ADDR'])) { http_response_code(429); header('Retry-After: 60'); echo '429 Too Many Requests'; return; }

Co zyskałeś na tej migracji:

  • Atomowość. compareAndSet($key, $expected, $next) albo zatwierdza nową wartość, albo zgłasza wyścig, aby wywołujący ponowił próbę — bez nadpisania typu odczyt-a-potem-zapis. setIfAbsent obsługuje przypadek „utwórz albo zostaw bez zmian" w jednym wywołaniu.
  • Deterministyczne czyszczenie. sweep() jest przewidywalne i działa według harmonogramu, który kontrolujesz.
  • O jedną zależność mniej. APCu znika z historii wdrożenia.
  • Dokładny licznik pod obciążeniem. Dwa równoczesne trafienia z tego samego IP nigdy się nawzajem nie nadpisują.
  • Naprawdę obejmujące cały proces. Powiązanie Registry::map sprawia, że kubełki są jedną współdzieloną instancją — ograniczenie liczby żądań dotyczy całego serwera, a nie pojedynczego workera.
Note

Wbudowana funkcja Ograniczanie liczby żądań (RATE_LIMIT=...) nadal działa na warstwie połączeń i jest szybsza niż limiter na poziomie PHP. Po własny limiter sięgaj tylko wtedy, gdy potrzebujesz polityki na poziomie PHP — per-tenant, per-route, per-user-id zamiast per-IP.

Kiedy wybrać Map, a kiedy Counter

Przykład używa Map<string, array{count,start}>, ponieważ każde IP wymaga utrzymywania dwóch pól w synchronizacji. Gdy potrzebujesz jedynie bieżącej sumy bez powiązanego stanu okna, Counter jest tańszy:

php
<?php $hits = new OxPHP\Shared\Counter(); // Every request increments, no read-modify-write cycle. $current = $hits->add(); // atomic increment-then-get

Sięgnij po Map<string, Counter> (mapę liczników), gdy potrzebujesz sum per-klucz bez logiki okna:

php
<?php // Per-tenant counters keyed by name. `Registry::counter` binds each // tenant's counter under a stable name so every worker converges on // the same entry — the factory runs at most once per name. $counter = OxPHP\Shared\Registry::counter( "tenant:{$tenantId}", fn () => new OxPHP\Shared\Counter(), ); $counter->add();

Semantyka uchwytów

Każdy obiekt Shared\* to cienki wrapper PHP wokół identyfikatora w rejestrze. Wynika z tego kilka reguł:

  1. Tożsamość to identyfikator w rejestrze, a nie obiekt PHP. Dwa uchwyty o tym samym identyfikatorze wskazują na ten sam stan. Testem równości jest $a->id() === $b->id().
  2. Serializacja jest zablokowana. serialize($counter) rzuca wyjątek. Rejestr żyje tylko w tym procesie — nie ma nic sensownego do przesłania po sieci. Skorzystaj z przewodnika migracji, gdy naprawdę musisz przekroczyć granicę procesu.
  3. clone jest zablokowany. Z tego samego powodu — sklonowany wrapper wyglądałby na odrębny, ale współdzieliłby stan. Skonstruuj nową instancję, jeśli chcesz mieć niezależną wartość.
  4. id() to nieprzejrzysty token właściwy dla procesu. Stabilny do momentu, gdy zniknie ostatnia referencja; można go bezpiecznie logować, dołączać do spanów śledzenia lub przekazywać do endpointów Obserwowalności serwera wewnętrznego w obrębie tego samego procesu. Wartość jest losowana przy starcie procesu i nie niesie żadnego znaczenia poza tym procesem — nie utrwalaj jej w zewnętrznym magazynie, sesjach, plikach cookie ani w innym workerze OxPHP. Nie ma konstruktora fromId(); identyfikator z obcego procesu nie odwzoruje się na nic.

Cykl życia

Wpisy są zliczane referencyjnie. Wpis w rejestrze pozostaje przy życiu, dopóki spełniony jest którykolwiek z poniższych warunków:

  • Referuje go wrapper PHP w dowolnym workerze.
  • Jest wartością przechowywaną wewnątrz innego żywego Shared\Map / Shared\Channel.
  • Oczekująca operacja asynchroniczna przechwyciła go w domknięciu.

Gdy licznik referencji osiągnie zero, rejestr wywołuje właściwe dla typu on_drop (które zwalnia zagnieżdżone referencje, zamyka kanały, zwalnia sloty puli itd.), a następnie zwalnia slot.

Większość typów nie ma jawnego close(). Shared\Channel je ma, ponieważ nadawcy i odbiorcy potrzebują sposobu, by zasygnalizować „koniec elementów"; nie zwalnia to jednak wpisu w rejestrze przedwcześnie. To wciąż wymaga usunięcia wszystkich referencji. Shared\Pool::evict() zwalnia bezczynne sloty, ale samą pulę pozostawia na miejscu.

Bezpieczeństwo cykli

Przechowywanie jednego obiektu Shareable wewnątrz innego jest dozwolone; przechowywanie A wewnątrz B, gdy B (bezpośrednio lub tranzytywnie) już sięga do A, utworzyłoby cykl i wyciek. Każda mutacja dodająca referencję najpierw wykonuje ograniczone przeszukiwanie BFS i odrzuca zapis wyjątkiem OxPHP\Shared\CycleException, zanim dotknie stanu:

php
<?php $a = new OxPHP\Shared\Map(); $b = new OxPHP\Shared\Map(); $a->set('b', $b); // fine try { $b->set('a', $a); // closes the loop — rejected } catch (OxPHP\Shared\CycleException $e) { // $b is untouched, no partial state, no leaked retain on $a }

Budżet przeszukiwania jest konfigurowalny: SHARED_CYCLE_DETECT_DEPTH (domyślnie 16) i SHARED_CYCLE_DETECT_EDGES (domyślnie 10 000). Bardzo duże, prawidłowe grafy, które przekroczą budżet, zgłaszają CycleException z komunikatem bounds exceeded.

Przekazywanie między wątkami

Uchwytów współdzielonych można bezpiecznie używać z dowolnego workera i z kontekstów asynchronicznych. Jedyna reguła: przekazuj je przez use, a nie global. Domknięcia przechwytywane przez operacje asynchroniczne potrzebują jawnych importów, aby środowisko uruchomieniowe mogło poprawnie utrzymywać liczniki referencji.

php
<?php $queue = new OxPHP\Shared\Channel(256); oxphp_async(function () use ($queue) { // ← explicit use while (($job = $queue->recvTimeout(30_000)) !== null) { process($job); } }); $queue->send(['url' => $_POST['url']]);

Instancje Shared\* nie są serializowalne, więc nie próbuj przesyłać ich przez polecenia powłoki, treści HTTP ani magazyn sesji.

Obserwowalność

Każdy wpis w rejestrze jest widoczny przez serwer wewnętrzny. Pełen przegląd znajdziesz w Obserwowalność stanu współdzielonego. W skrócie:

  • GET /__ox_shared/summary — zbiorcze liczby, pamięć i operacje według typu.
  • GET /__ox_shared/entries — każdy żywy wpis z identyfikatorem, typem, licznikiem referencji i rozmiarem.
  • GET /__ox_shared/entry?id=N — szczegóły właściwe dla typu jednego wpisu.
  • GET /__ox_shared/graph?id=N — przejście BFS po wychodzących referencjach (przydatne po CycleException).
  • /metrics — liczniki i wskaźniki Prometheusa z prefiksem oxphp_shared_*.

Wyłącz introspekcję w produkcyjnych konfiguracjach z niezaufanymi tenantami za pomocą SHARED_INTROSPECTION_ENABLED=false; metryki pozostają włączone.

Konfiguracja

Wszystkie zmienne środowiskowe są odczytywane przy starcie. Wartości domyślne są dobrane do setek wpisów na pojedynczym hoście; zwiększ je w przypadku wdrożeń intensywnie korzystających z rejestru.

Zmienna środowiskowa Domyślnie Efekt
SHARED_MAX_ENTRIES 100 000 Globalny limit na wszystkie wpisy Shared łącznie. Wstawienie ponad ten limit zawodzi.
SHARED_MAX_BYTES 1 GiB Globalny limit szacowanej pamięci wszystkich wpisów Shared.
SHARED_SOFT_LIMIT_RATIO 0.7 Zaczyna odrzucać pracę o najniższym priorytecie, gdy zużycie przekroczy ten ułamek.
SHARED_CYCLE_DETECT_DEPTH 16 Głębokość BFS podczas sprawdzania cykli. Zwiększ dla głębokich, prawidłowych grafów.
SHARED_CYCLE_DETECT_EDGES 10 000 Liczba krawędzi przechodzonych podczas sprawdzania cykli. Zwiększ dla gęstych, prawidłowych grafów.
SHARED_PREVIEW_ARRAY_LIMIT 20 Liczba wpisów próbkowanych w podglądach /entry?id=….
SHARED_PREVIEW_STRING_LIMIT 256 Obcinanie pojedynczych ciągów w podglądach.
SHARED_INTROSPECTION_ENABLED true Przełącza API /__ox_shared/*.
SHARED_METRICS_ENABLED true Przełącza ekspozycję Prometheusa oxphp_shared_*.

Kiedy nie używać

Stan współdzielony działa w obrębie procesu. To ogranicza sytuacje, w których się nadaje.

  • Wiele hostów. Jeśli uruchamiasz więcej niż jeden proces OxPHP (typowa sytuacja dla czegokolwiek poza pojedynczą maszyną), workery w procesie A nie widzą wpisów Shared\* w procesie B. Użyj Redisa, NATS-a lub czegokolwiek, co już wdrażasz. Migracja do zewnętrznego magazynu omawia typowe wzorce.
  • Trwałość. Stan współdzielony ulatnia się przy restarcie procesu. Jeśli twój licznik ma przetrwać wdrożenie, utrwal go gdzie indziej.
  • Nieograniczone wartości. Shared\Map bez maxEntries może zostać doprowadzona do OOM przez atakującego. Zawsze ustawiaj limit na wszystko, co jest kluczowane danymi od użytkownika.
  • Duże ładunki. Wartości są kopiowane przez granicę FFI przy odczycie. Upychanie tablic o rozmiarze 10 MB w Shared\Map to zły pomysł — umieść blob w magazynie obiektów i udostępnij URL.
  • Zastępowanie buforowania OPcache / APCu. OPcache już buforuje skompilowane skrypty; APCu buforuje dane o zasięgu żądania per worker (co jest tańsze, gdy naprawdę nie potrzebujesz widoczności między workerami).

Częste pułapki

  • Zapominanie o limicie dla Shared\Map. Nieograniczone mapy kluczowane adresami IP / identyfikatorami użytkowników / tokenami sesji to najczęstsza samodzielnie wywołana awaria OOM. Zawsze przekazuj maxEntries i przechwytuj CapacityException.
  • Odczytywanie całej tablicy przy każdym żądaniu. Map::get kopiuje. Jeśli sięgasz po dużą tablicę kilkadziesiąt razy na żądanie, zapisz kopię w zmiennej o zasięgu żądania.
  • Traktowanie recv / get jako nie-null. Każdy odczyt może zgodnie z zasadami zwrócić null (zamknięty kanał, brakujący klucz). Zawsze sprawdzaj null.
  • Używanie global z async. Fibery uruchamiane przez oxphp_async potrzebują swoich przechwyceń w klauzuli use (…). Referencje global nie są śledzone.
  • Niespodzianka z clone. clone $counter rzuca wyjątek. Początkujący często tego próbują; naucz się alternatywy (new Shared\Counter($counter->get())) raz a dobrze.

Powiązane