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 (123i"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 instancjaShareable.nulloznacza brak — nigdy nie jest wartością przechowywaną. Zapis wartościnullrzucaTypeException; zwróconynullzawsze 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.ConcurrentHashMapisync.Mapz Go).- Jeden linearyzowalny prymityw warunkowy.
compareAndSetobejmuje atomowe wstawianie / zastępowanie / usuwanie poprzez wartownik brakunull; 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 przezCycleExceptionjeszcze przed jakąkolwiek mutacją — brak wycieków na odrzuconej ścieżce. - Ograniczona przybliżonym miękkim limitem.
maxEntriesto pułap bezpieczeństwa chroniący przed OOM, a nie dokładne rozliczanie.
Dokumentacja API
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/setIfAbsentz wartościąnull→TypeException.get/swap/pop/setIfAbsentzwracającenull⟺ klucz nie istniał.- W
compareAndSetnullpo 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
$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 === $aZwraca 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:
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 coConcurrentHashMap::size). Rozłożenie na paski (striping) trzyma zapisy z dala od jednego „gorącego” licznika.maxEntriesto 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 zCapacityException. 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.forEachuruchamia 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óćfalsez 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
$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
$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
$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
$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
$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 5Aby 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
$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 wrapperWykrywanie cykli odrzuca, zanim dokona mutacji
<?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 retainsZagnież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
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
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
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 tymMap.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,saturationorazsample_keys(obcięte limitem podglądu).GET /__ox_shared/graph?id=N[&depth=D][&edges=E]— obchód BFS wychodzących referencji Shareable; przydatny poCycleException.
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, gdysetIfAbsenturuchamiał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.