Shared\Map

OxPHP\Shared\Map to współbieżna mapa, która żyje we współdzielonym rejestrze i jest widoczna dla każdego workera PHP w procesie. To podstawowy prymityw, gdy dwa workery — albo obsługa żądania i zadanie w tle — muszą współdzielić mutowalny stan przetrwający cykl życia żądania.

Przegląd

  • int|string → mixed. Klucze to liczby całkowite lub łańcuchy znaków PHP, przechowywane osobno (123 i "123" to różne klucze; nie ma tu koercji kluczy w stylu tablic PHP). Klucze łańcuchowe są binarnie bezpieczne — przechowywane jako nieprzejrzyste bajty (jak tablice PHP / Go / Redis), więc klucze spoza UTF-8 (w tym z osadzonym NUL) wiernie przechodzą pełny cykl zapisu i odczytu. Wartością może być dowolny skalar, tablica skalarów/tablic albo inna instancja Shareable.
  • null oznacza brak — nigdy nie jest wartością przechowywaną. Zapis wartości null rzuca TypeException; zwrócony null zawsze oznacza „nie ma takiego klucza”. To u samych podstaw usuwa klasyczną niejednoznaczność „get() zwrócił null” (ten sam wybór, którego dokonują java.util.concurrent.ConcurrentHashMap i sync.Map z Go).
  • Jeden linearyzowalny prymityw warunkowy. compareAndSet obejmuje atomowe wstawianie / zastępowanie / usuwanie poprzez wartownik braku null; dowolną operację read-modify-write buduj na jego bazie.
  • Współbieżna. Zapisy z różnych workerów nie wymagają zewnętrznego blokowania; operacje na poszczególnych kluczach są atomowe na poziomie sharda.
  • Bezpieczna wobec cykli. Zapis Shareable, który sięgnąłby z powrotem do tej mapy, zostaje odrzucony przez CycleException jeszcze przed jakąkolwiek mutacją — brak wycieków na odrzuconej ścieżce.
  • Ograniczona przybliżonym miękkim limitem. maxEntries to pułap bezpieczeństwa chroniący przed OOM, a nie dokładne rozliczanie.

Dokumentacja API

php
namespace OxPHP\Shared; final class Map implements Shareable { public function __construct(?int $maxEntries = null); // null = unbounded; <= 0 throws TypeException // reads public function get(int|string $key): mixed; // null ⟺ absent public function getMany(iterable $keys): \Iterator; // lazy; skips absent keys public function count(): int; // striped, weakly consistent public function maxEntries(): ?int; // writes public function set(int|string $key, mixed $value): void; public function setIfAbsent(int|string $key, mixed $value): mixed; // prev; null ⟺ inserted public function setMany(iterable $entries): int; public function remove(int|string $key): bool; // existed? public function removeMany(iterable $keys): int; public function clear(): int; // entries removed // value-returning public function swap(int|string $key, mixed $value): mixed; // prev; null ⟺ was absent public function pop(int|string $key): mixed; // prev; null ⟺ was absent // conditional (single linearisable primitive) public function compareAndSet(int|string $key, mixed $expected, mixed $new): bool; // iteration public function forEach(callable $fn): void; // weakly consistent; callback runs lock-free public function id(): int; }
Metoda Zastosowanie
__construct Tworzy z opcjonalnym limitem maxEntries (null = bez ograniczeń; <= 0 rzuca wyjątek).
get Pobiera po kluczu; null ⟺ brak.
getMany Leniwie strumieniuje key => value dla znanych kluczy; brakujące klucze są pomijane (patrz niżej).
count Przybliżona liczba wpisów (słabo spójna przy współbieżnych zapisach).
maxEntries Zgłasza skonfigurowany limit (lub null, gdy bez ograniczeń).
set Wstawia lub zastępuje; poprzednia wartość nie jest materializowana.
setIfAbsent Atomowe wstawienie przy braku; zwraca istniejącą wartość lub null, jeśli nastąpiło wstawienie.
setMany Masowe wstawianie z dowolnego iterowalnego; zwraca liczbę zapisanych.
remove Usuwa klucz; zwraca informację, czy istniał (wartość nie jest materializowana).
removeMany Masowe usuwanie; zwraca liczbę faktycznie usuniętych.
clear Usuwa wszystkie wpisy (zwalniając uchwyty zagnieżdżonych Shareable); zwraca liczbę usuniętych.
swap Nadpisuje i zwraca poprzednią wartość (null ⟺ nie istniała).
pop Usuwa i zwraca poprzednią wartość (null ⟺ nie istniała).
compareAndSet Atomowe wstawianie / zastępowanie / usuwanie zależne od bieżącej zawartości (patrz niżej).
forEach Słabo spójne przejście; callback wykonuje się bez trzymania blokady.
id Numeryczny identyfikator w rejestrze; przydatny do logowania i /__ox_shared/entry?id=….

Nie ma metod has(), update(), getOrSet(), keys(), trySet(), updateMany(), a klasa nie implementuje Countable — patrz Migracja ze starego interfejsu.

Model „null jako brak”

null jest wszędzie zarezerwowany jako wartownik braku:

  • set / swap / setIfAbsent z wartością nullTypeException.
  • get / swap / pop / setIfAbsent zwracające null ⟺ klucz nie istniał.
  • W compareAndSet null po dowolnej stronie oznacza „brak” (a nie „zapisz null”).

Jeśli musisz odnotować „brak wartości”, usuń klucz (albo wykorzystaj brak klucza), zamiast zapisywać null. Ponieważ nie ma metody has() ani wyścigu między współbieżnymi has()+get(), obecność sprawdzasz atomowo pojedynczym get($k) !== null.

compareAndSet — prymityw warunkowy

php
$map->compareAndSet($key, expected: null, new: $v); // insert iff absent (= setIfAbsent, returns bool) $map->compareAndSet($key, expected: $a, new: $b); // replace iff current === $a $map->compareAndSet($key, expected: $a, new: null); // remove iff current === $a

Zwraca true tylko wtedy, gdy zamiana została zastosowana. Równość jest określana po zawartości: skalary według wartości, łańcuchy i tablice według ich zserializowanych bajtów, a zagnieżdżone wartości Shareable — według tożsamości w rejestrze. Równość tablic pokrywa się z PHP === w typowych przypadkach (listy, tablice z kluczami wyłącznie int lub wyłącznie string); tablice, które przeplatają klucze int i string, są porównywane w znormalizowanej postaci przechowywania mapy, więc konkretna kolejność int/string nie jest rozróżniana (mapa również przestawia takie tablice przy odczycie). Buduj read-modify-write jako jawną pętlę ponawiania — i utrzymuj domknięcie czystym, ponieważ przy rywalizacji wykonuje się ono więcej niż raz:

php
do { $cur = $map->get('counter'); // null if absent $next = ($cur ?? 0) + 1; } while (!$map->compareAndSet('counter', $cur, $next));

Nie ma tu zagrożenia ABA: magazyn jest adresowany zawartością (wartość równa co do zawartości jest tą samą wartością dla magazynu wartości), a tożsamość zagnieżdżonych Shareable używa monotonicznych, nigdy nie używanych ponownie id rejestru. Do odpornej na nawałnicę (stampede) leniwej inicjalizacji użyj Shared\Once; do pul zasobów — Shared\Pool.

Model pamięci — gdzie zachodzą kopie

Wartości są przechowywane w postaci zserializowanej, a nie jako zval — więc „zero-copy” nie ma zastosowania do wartości:

Operacja Serializacja do współdzielonej sterty Materializacja poprzedniej wartości do zvala
set / setMany tak nie
remove / removeMany nie
setIfAbsent tak tylko jeśli poprzednia wartość istnieje
swap / pop tak / — tak
get / getMany (tylko klucz) tak
compareAndSet tak ($new) nie

Serializacja na ścieżce zapisu jest nieunikniona dla każdej wartości trafiającej do pamięci współdzielonej. Odczyt z powrotem do świeżego zvala opłacają tylko metody zwracające poprzednią/odczytaną wartość — dlatego set/remove oznaczają „brak materializacji zwrotu”, a nie „za darmo”. Wyjątkiem jest zagnieżdżona wartość Shareable: przechowywana jest przez referencję (id + inkrementacja licznika referencji), a nie kopiowana w całości.

Współbieżność

  • count() jest słabo spójny. Liczniki wpisów są rozłożone na paski w poszczególnych shardach (striped) i sumowane przy odczycie; wynik jest dokładny, gdy mapa jest w spoczynku, i stanowi bliskie przybliżenie przy współbieżnych zapisach (ten sam kontrakt co ConcurrentHashMap::size). Rozłożenie na paski (striping) trzyma zapisy z dala od jednego „gorącego” licznika.
  • maxEntries to miękki limit. Sprawdzany jest względem sumy z pasków, więc przy współbieżnych wstawieniach mapa może go przekroczyć nawet o liczbę shardów, zanim odrzuci nowy klucz z CapacityException. Traktuj go jako budżet bezpieczeństwa chroniący przed OOM, a nie dokładne rozliczanie. Nadpisanie istniejącego klucza przy osiągniętym limicie zawsze się powiedzie. Nie ma eksmisji (eviction) — cache z eksmisją LRU/TTL to inny prymityw.
  • forEach uruchamia callback bez trzymania blokady. Robi migawkę kluczy jednego sharda naraz, zwalnia shard, następnie ponownie pobiera każdą wartość i wywołuje $fn(key, value). Klucze usunięte między migawką a wywołaniem są pomijane; klucze dodane po migawce danego sharda mogą zostać przeoczone; wartości mogą być świeższe niż moment migawki. Zwróć false z callbacku, aby zakończyć wcześniej. Ponieważ migawka obejmuje tylko klucze, wolny callback nigdy nie przytrzymuje usuniętych wartości.

Przykłady

Cache współdzielonej konfiguracji

php
<?php $config = new OxPHP\Shared\Map(maxEntries: 1024); // Warm once at app bootstrap. $config->setMany([ 'rate_limit.default_rpm' => 600, 'feature.new_checkout' => true, 'timeout.downstream_ms' => 250, ]); // Any request handler reads without contention; null ⟺ not configured. $rpm = $config->get('rate_limit.default_rpm') ?? 60;

Ogranicznik liczby żądań na najemcę

php
<?php $buckets = new OxPHP\Shared\Map(maxEntries: 50_000); $key = "tenant:{$tenantId}"; $prev = $buckets->setIfAbsent($key, ['tokens' => 100, 'refill_at' => time() + 60]); // $prev === null ⟺ we created the bucket; otherwise it holds the existing one. $state = $buckets->get($key); if ($state['tokens'] === 0) { throw new RateLimitException(); }

Koordynacja liczników między workerami

php
<?php $counters = new OxPHP\Shared\Map(); $counters->set('requests_handled', new OxPHP\Shared\Counter()); // Any worker increments via the stored Shareable (stored by reference). $counters->get('requests_handled')->add();

Iteracja po dużej mapie

php
<?php $sessions->forEach(function (int|string $key, mixed $value): bool|null { if ($value['expires_at'] < time()) { // safe: forEach holds no lock during the callback return null; // keep going } return null; }); // Or read a known subset lazily, stopping early: foreach ($cache->getMany($hotKeys) as $key => $value) { if (enoughCollected()) break; // remaining keys are never materialised handle($key, $value); }

Semantyka i pułapki

Tablice są kopiowane przy odczycie

php
<?php $m = new OxPHP\Shared\Map(); $m->set('cfg', ['timeout' => 5, 'retries' => 3]); $cfg = $m->get('cfg'); $cfg['timeout'] = 10; // mutates the returned copy only // $m->get('cfg')['timeout'] is still 5

Aby atomowo zaktualizować wartość będącą tablicą, odczytaj ją, zmodyfikuj kopię i zatwierdź przez compareAndSet (ponawiając przy konflikcie), albo przechowuj niezależnie zmieniające się pola jako zagnieżdżone Shared\Counter / Shared\Map.

Utrzymania zagnieżdżonych Shareable są automatyczne

php
<?php $map = new OxPHP\Shared\Map(); $counter = new OxPHP\Shared\Counter(10); $map->set('c', $counter); $retrieved = $map->get('c'); // same Shareable identity $retrieved->add(); // mutation visible via $counter too echo $counter->get(); // 11 $counter2 = $map->pop('c'); // Map releases its hold, returns the value $counter2->add(); // still alive via the returned wrapper

Wykrywanie cykli odrzuca, zanim dokona mutacji

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 } catch (OxPHP\Shared\CycleException $e) { // message: "cycle would form: #… → #… (inserting into #…)" } $b->get('a'); // null — $b untouched, no leaked retains

Zagnieżdżone referencje wewnątrz tablic również są sprawdzane. Mechanizm przechodzenia grafu jest ograniczony przez SHARED_CYCLE_DETECT_DEPTH (domyślnie 16) i SHARED_CYCLE_DETECT_EDGES (domyślnie 10 000); bardzo duże grafy kończą się CycleException z komunikatem bounds exceeded — podnieś wartości zmiennych env albo rozbij graf.

Limit rozmiaru pojedynczej wartości

Note

Pojedyncza wartość, której zserializowany rozmiar przekracza SHARED_MAX_VALUE_SIZE (domyślnie 1 MiB), zostaje odrzucona przez ValueTooLargeException. To chroni przed „bombą alokacji pamięci” z danych po stronie PHP. Dotyczy każdej ścieżki zapisu (set, setIfAbsent, swap, compareAndSet, setMany).

Operacje wsadowe są atomowe per klucz, a nie per wsad

Warning

setMany, getMany i removeMany stosują po jednym kluczu naraz. Jeśli setMany w połowie natrafi na CapacityException, CycleException lub ValueTooLargeException, wcześniejsze klucze pozostają zapisane — częściowy sukces jest zamierzony. Użyj Shared\Mutex wokół mapy, jeśli potrzebujesz semantyki „wszystko albo nic”.

Migracja ze starego interfejsu

Zmiana łamiąca zgodność

To przebudowa łamiąca zgodność, pozbawiona jakichkolwiek warstw kompatybilności.

Stare Nowe
has($k) get($k) !== null (atomowo — bez wyścigu has/get)
get($k, $default) get($k) ?? $default
trySet($k, $v): bool setIfAbsent($k, $v): mixed (zwraca poprzednią; null ⟺ wstawiono)
remove($k) (zwracał poprzednią) remove($k): bool lub pop($k), aby uzyskać wartość
update($k, $fn) pętla ponawiania compareAndSet lub Shared\Once dla jednorazowej inicjalizacji
getOrSet($k, $fn) setIfAbsent albo Shared\Once / Shared\Pool zależnie od przypadku
updateMany(...) pętla z compareAndSet
keys(): array forEach(...) lub getMany($knownKeys)
count($map) (Countable) $map->count()
przechowywanie wartości null użyj braku klucza / remove

Wyjątki

Wszystkie metody, które mogą się nie powieść, rzucają podklasy OxPHP\Shared\SharedException:

Wyjątek Rzucany przez
CapacityException Nowy klucz ponad maxEntries (set / setIfAbsent / compareAndSet / setMany).
ValueTooLargeException Wartość ponad limit pojedynczej wartości (SHARED_MAX_VALUE_SIZE).
CycleException Zapis, który zamknąłby cykl osiągalności (extends TypeException).
TypeException Wartość null; wartość niemożliwa do przechowania (object/closure/resource); klucz inny niż int/string; maxEntries <= 0.
StaleHandleException Wywołanie metody na uchwycie, którego wpis w rejestrze został wyeksmitowany.

Obserwowalność

Każda mapa jest widoczna przez wewnętrzne API:

  • GET /__ox_shared/summary — zagregowane liczby według typu, w tym Map.
  • GET /__ox_shared/entries — lista wszystkich wpisów z id / type / refcount / mem_bytes.
  • GET /__ox_shared/entry?id=N — szczegóły konkretnej instancji dla Map obejmują key_count, max_entries, saturation oraz sample_keys (obcięte limitem podglądu).
  • GET /__ox_shared/graph?id=N[&depth=D][&edges=E] — obchód BFS wychodzących referencji Shareable; przydatny po CycleException.

Prometheus udostępnia dla każdej mapy metryki typu gauge pod /metrics:

Metryka Znaczenie
oxphp_shared_map_entries{map_id="…"} Bieżąca (przybliżona) liczba kluczy.
oxphp_shared_map_max_entries{map_id="…"} Skonfigurowany limit (0, gdy bez ograniczeń).
oxphp_shared_map_saturation{map_id="…"} entries / max_entries, 0 gdy bez ograniczeń.

Konfiguracja

Zmienna env Domyślnie Efekt
SHARED_MAX_ENTRIES 100 000 Globalny limit na wszystkie wpisy Shared łącznie.
SHARED_MAX_BYTES 1 GiB Globalny limit na szacowaną pamięć wszystkich wpisów Shared.
SHARED_MAX_VALUE_SIZE 1 MiB Limit zserializowanego rozmiaru pojedynczej wartości; większe wartości rzucają ValueTooLargeException.
SHARED_CYCLE_DETECT_DEPTH 16 Maksymalna głębokość BFS podczas sprawdzania cykli. Podnieś dla głębokich, prawidłowych grafów.
SHARED_CYCLE_DETECT_EDGES 10 000 Maksymalna liczba krawędzi przechodzonych podczas sprawdzania cykli. Podnieś dla gęstych, prawidłowych grafów.
SHARED_PREVIEW_ARRAY_LIMIT 20 Liczba wpisów próbkowanych w sample_keys w /entry?id=….
SHARED_INTROSPECTION_ENABLED true Włącza/wyłącza API /__ox_shared/*.

Powiązane

  • Shared\Counter — atomowa liczba całkowita; przechowuj wewnątrz mapy do zliczania trafień per klucz.
  • Shared\Once — odporna na nawałnicę leniwa inicjalizacja, gdy setIfAbsent uruchamiałby ponownie kosztowną fabrykę.
  • Shared\Channel — kolejka MPMC; uzupełnienie, gdy potrzebujesz potoków FIFO zamiast wyszukiwania po kluczu.
  • Shared\Mutex — gdy potrzebujesz ścisłego wzajemnego wykluczania wokół przechowywanej wartości.