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\Counterto atomowa liczba całkowita.Shared\Mapto współbieżna mapa haszująca. Nie musisz od nowa implementować poprawności na bazie semantykiINCR. - 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.
clonejest 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 przyclone.- 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) { ... }). Importuseprzenosi 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) |
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
<?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
oxphp_async(function () use ($requests) {
$requests->add(); // runs on whatever worker picks the fiber up
});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
// 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
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.setIfAbsentobsł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::mapsprawia, że kubełki są jedną współdzieloną instancją — ograniczenie liczby żądań dotyczy całego serwera, a nie pojedynczego workera.
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
$hits = new OxPHP\Shared\Counter();
// Every request increments, no read-modify-write cycle.
$current = $hits->add(); // atomic increment-then-getSięgnij po Map<string, Counter> (mapę liczników), gdy potrzebujesz sum per-klucz bez logiki okna:
<?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ł:
- 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(). - 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. clonejest 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ść.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 konstruktorafromId(); 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
$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
$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 poCycleException)./metrics— liczniki i wskaźniki Prometheusa z prefiksemoxphp_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\MapbezmaxEntriesmoż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\Mapto 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 przekazujmaxEntriesi przechwytujCapacityException. - Odczytywanie całej tablicy przy każdym żądaniu.
Map::getkopiuje. Jeśli sięgasz po dużą tablicę kilkadziesiąt razy na żądanie, zapisz kopię w zmiennej o zasięgu żądania. - Traktowanie
recv/getjako nie-null. Każdy odczyt może zgodnie z zasadami zwrócićnull(zamknięty kanał, brakujący klucz). Zawsze sprawdzaj null. - Używanie
globalz async. Fibery uruchamiane przezoxphp_asyncpotrzebują swoich przechwyceń w klauzuliuse (…). Referencjeglobalnie są śledzone. - Niespodzianka z clone.
clone $counterrzuca wyjątek. Początkujący często tego próbują; naucz się alternatywy (new Shared\Counter($counter->get())) raz a dobrze.
Powiązane
- Shared\Counter — akumulator dziedzinowy.
- Shared\Atomic — ogólny atomowy int64 z pełną kontrolą porządkowania pamięci.
- Shared\Flag — atomowy bool / wyłącznik awaryjny.
- Shared\Once — jednokrotne wykonanie między workerami.
- Shared\Mutex — zatruwalny muteks nad wartością.
- Shared\Channel — ograniczona kolejka MPMC.
- Shared\Map — współbieżny magazyn z kluczem.
- Shared\Pool — ograniczona pula obiektów.
- Shared\Registry — uchwyty globalne dla procesu kluczowane nazwą (historia międzyworkerowa / międzyżądaniowa).
- Obserwowalność stanu współdzielonego — introspekcja, metryki, diagnostyka.
- Migracja do zewnętrznego magazynu — gdy stan współdzielony przerasta pojedynczy host.