Shared\Map

OxPHP\Shared\Map — это конкурентная хеш-таблица (map), которая живёт в разделяемом реестре и видна каждому PHP-воркеру в процессе. Это основной примитив, когда двум воркерам — или обработчику запроса и фоновой задаче — нужно совместно использовать изменяемое состояние, переживающее жизненный цикл запроса.

Обзор

  • int|string → mixed. Ключи — это целые числа или строки PHP, которые хранятся раздельно (123 и "123" — разные ключи; приведения ключей в стиле PHP-массивов нет). Строковые ключи бинарно-безопасны — хранятся как непрозрачные байты (как в PHP-массивах / Go / Redis), поэтому не-UTF-8 ключи (включая встроенный NUL) корректно проходят туда и обратно. Значениями могут быть любой скаляр, массив скаляров/массивов или другой экземпляр Shareable.
  • null означает отсутствие — а не хранимое значение. Запись значения null бросает TypeException; возврат null всегда означает «такого ключа нет». Это в корне устраняет классическую неоднозначность «get() вернул null» (тот же выбор, что делают java.util.concurrent.ConcurrentHashMap и sync.Map в Go).
  • Один линеаризуемый условный примитив. compareAndSet покрывает атомарные вставку / замену / удаление через маркер отсутствия null; любую операцию read-modify-write стройте поверх него.
  • Конкурентность. Записи из разных воркеров не требуют внешней блокировки; операции над отдельными ключами атомарны на уровне шарда.
  • Безопасность к циклам. Сохранение Shareable, который бы замкнулся обратно на эту Map, отклоняется с CycleException до любой мутации — на отклонённом пути нет утечек.
  • Ограничение мягким приблизительным потолком. maxEntries — это потолок для защиты от OOM, а не точный учёт.

Справочник по 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; }
Метод Применение
__construct Создаёт с необязательным лимитом maxEntries (null = без ограничения; <= 0 бросает).
get Получает по ключу; null ⟺ отсутствует.
getMany Лениво отдаёт поток key => value для известных ключей; отсутствующие ключи пропускаются (см. ниже).
count Приблизительное число записей (слабо согласованное при конкурентных записях).
maxEntries Сообщает настроенный лимит (или null, если без ограничения).
set Вставляет или заменяет; предыдущее значение не материализуется.
setIfAbsent Атомарная вставка при отсутствии; возвращает существующее значение или null, если вставка произошла.
setMany Массовая вставка из любого iterable; возвращает число записанных.
remove Удаляет ключ; возвращает, существовал ли он (значение не материализуется).
removeMany Массовое удаление; возвращает число фактически удалённых.
clear Удаляет все записи (освобождая удержания вложенных Shareable); возвращает число удалённых.
swap Перезаписывает и возвращает предыдущее значение (null ⟺ отсутствовало).
pop Удаляет и возвращает предыдущее значение (null ⟺ отсутствовало).
compareAndSet Атомарная вставка / замена / удаление в зависимости от текущего содержимого (см. ниже).
forEach Слабо согласованный обход; колбэк выполняется без удержания блокировки.
id Числовой идентификатор в реестре; полезен для логирования и /__ox_shared/entry?id=….

Методов has(), update(), getOrSet(), keys(), trySet(), updateMany() нет, и класс не реализует Countable — см. Миграция со старого интерфейса.

Модель «null как отсутствие»

null везде зарезервирован как маркер отсутствия:

  • set / swap / setIfAbsent со значением nullTypeException.
  • get / swap / pop / setIfAbsent, возвращающие null ⟺ ключ отсутствовал.
  • В compareAndSet значение null с любой стороны означает «отсутствует» (а не «сохранить null»).

Если нужно зафиксировать «нет значения», удалите ключ (или используйте отсутствие ключа), а не сохраняйте null. Поскольку метода has() нет и нет гонки между конкурентными has()+get(), наличие проверяется атомарно одним get($k) !== null.

compareAndSet — условный примитив

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

Возвращает true, только если замена была применена. Равенство определяется по содержимому: скаляры по значению, строки и массивы по их сериализованным байтам, а вложенные значения Shareable — по идентичности в реестре. Равенство массивов совпадает с PHP === в типичных случаях (списки, массивы с ключами только-int или только-string); массивы, в которых чередуются int- и string-ключи, сравниваются по нормализованной форме хранения Map, поэтому конкретный порядок int/string не различается (Map также переупорядочивает такие массивы при чтении обратно). Стройте read-modify-write как явный цикл повторов — и держите замыкание чистым, поскольку при конкуренции оно выполняется более одного раза:

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

Здесь нет опасности ABA: хранилище адресуется по содержимому (значение, равное по содержимому, и есть то же самое значение для хранилища значений), а идентичность вложенных Shareable использует монотонные, никогда не переиспользуемые id реестра. Для устойчивой к «набегу» (stampede) ленивой инициализации используйте Shared\Once; для пулов ресурсов — Shared\Pool.

Модель памяти — где происходят копирования

Значения хранятся в сериализованном представлении, а не как zval, поэтому «zero-copy» к значениям не применяется:

Операция Сериализация в разделяемую кучу Материализация предыдущего значения в zval
set / setMany да нет
remove / removeMany нет
setIfAbsent да только если предыдущее значение существует
swap / pop да / — да
get / getMany (только ключ) да
compareAndSet да ($new) нет

Сериализация на пути записи неизбежна для любого значения, попадающего в разделяемую память. Чтение обратно в свежий zval оплачивают только методы, возвращающие предыдущее/найденное значение — поэтому set/remove означают «нет материализации возврата», а не «бесплатно». Исключение — вложенное значение Shareable: оно хранится по ссылке (id + инкремент счётчика ссылок), а не копируется целиком.

Конкурентность

  • count() слабо согласован. Счётчики записей распределены по шардам (striped) и суммируются при чтении; результат точен, когда map находится в покое, и близкое приближение при конкурентных записях (тот же контракт, что и у ConcurrentHashMap::size). Распределение (striping) удерживает записи от единого «горячего» счётчика.
  • maxEntries — мягкий лимит. Он проверяется по распределённой (striped) сумме, поэтому при конкурентных вставках map может превысить его вплоть до числа шардов, прежде чем отклонить новый ключ с CapacityException. Считайте его бюджетом для защиты от OOM, а не точным учётом. Перезапись существующего ключа при достижении лимита всегда успешна. Вытеснения (eviction) нет — кеш с вытеснением по LRU/TTL — это другой примитив.
  • forEach выполняет колбэк без удержания блокировки. Он делает снимок ключей одного шарда за раз, освобождает шард, затем заново получает каждое значение и вызывает $fn(key, value). Ключи, удалённые между снимком и вызовом, пропускаются; ключи, добавленные после снимка шарда, могут быть не замечены; значения могут быть свежее момента снимка. Верните false из колбэка, чтобы остановиться раньше. Поскольку снимок делается только для ключей, медленный колбэк никогда не удерживает удалённые значения.

Примеры

Кеш разделяемой конфигурации

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;

Ограничитель частоты запросов по арендаторам

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(); }

Координация счётчиков между воркерами

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();

Итерация по большой map

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); }

Семантика и подводные камни

Массивы копируются при чтении

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

Чтобы атомарно обновить значение-массив, прочитайте его, измените копию и зафиксируйте через compareAndSet (повторяя при конфликте), либо храните независимо изменяющиеся поля как вложенные Shared\Counter / Shared\Map.

Удержания вложенных Shareable автоматические

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

Обнаружение циклов отклоняет до мутации

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

Вложенные ссылки внутри массивов также проверяются. Обходчик ограничен SHARED_CYCLE_DETECT_DEPTH (по умолчанию 16) и SHARED_CYCLE_DETECT_EDGES (по умолчанию 10 000); очень большие графы приводят к CycleException с bounds exceeded — поднимите значения env-переменных или разбейте граф.

Ограничение размера отдельного значения

Note

Отдельное значение, чей сериализованный размер превышает SHARED_MAX_VALUE_SIZE (по умолчанию 1 MiB), отклоняется с ValueTooLargeException. Это защищает от «бомбы выделения памяти» из данных со стороны PHP. Применяется ко всем путям записи (set, setIfAbsent, swap, compareAndSet, setMany).

Пакетные операции атомарны по ключу, а не по пакету

Warning

setMany, getMany и removeMany применяют по одному ключу за раз. Если setMany на полпути наткнётся на CapacityException, CycleException или ValueTooLargeException, ранее записанные ключи остаются сохранёнными — частичный успех сделан намеренно. Используйте Shared\Mutex вокруг map, если нужна семантика «всё или ничего».

Миграция со старого интерфейса

Ломающее изменение

Это ломающая переработка без слоёв совместимости.

Старое Новое
has($k) get($k) !== null (атомарно — без гонки has/get)
get($k, $default) get($k) ?? $default
trySet($k, $v): bool setIfAbsent($k, $v): mixed (возвращает предыдущее; null ⟺ вставлено)
remove($k) (возвращал предыдущее) remove($k): bool или pop($k), чтобы получить значение
update($k, $fn) цикл повторов compareAndSet или Shared\Once для однократной инициализации
getOrSet($k, $fn) setIfAbsent либо Shared\Once / Shared\Pool по ситуации
updateMany(...) цикл из compareAndSet
keys(): array forEach(...) или getMany($knownKeys)
count($map) (Countable) $map->count()
сохранение значения null используйте отсутствие ключа / remove

Исключения

Все методы, которые могут завершиться неудачей, бросают подклассы OxPHP\Shared\SharedException:

Исключение Когда бросается
CapacityException Новый ключ сверх maxEntries (set / setIfAbsent / compareAndSet / setMany).
ValueTooLargeException Значение сверх лимита на отдельное значение (SHARED_MAX_VALUE_SIZE).
CycleException Запись, которая замкнула бы цикл достижимости (extends TypeException).
TypeException Значение null; несохраняемое значение (object/closure/resource); ключ не int/string; maxEntries <= 0.
StaleHandleException Вызов метода на хендле, чья запись в реестре была вытеснена.

Наблюдаемость

Каждая Map видна через внутренний API:

  • GET /__ox_shared/summary — агрегированные счётчики по типам, включая Map.
  • GET /__ox_shared/entries — список всех записей с id / type / refcount / mem_bytes.
  • GET /__ox_shared/entry?id=N — детали по конкретному экземпляру для Map включают key_count, max_entries, saturation и sample_keys (обрезаются лимитом предпросмотра).
  • GET /__ox_shared/graph?id=N[&depth=D][&edges=E] — BFS-обход исходящих ссылок Shareable; удобно после CycleException.

Prometheus публикует gauge-метрики по каждой Map на /metrics:

Метрика Значение
oxphp_shared_map_entries{map_id="…"} Текущее (приблизительное) число ключей.
oxphp_shared_map_max_entries{map_id="…"} Настроенный лимит (0, если без ограничения).
oxphp_shared_map_saturation{map_id="…"} entries / max_entries, 0, если без ограничения.

Конфигурация

Env-переменная По умолчанию Действие
SHARED_MAX_ENTRIES 100 000 Глобальный лимит на все записи Shared вместе.
SHARED_MAX_BYTES 1 GiB Глобальный лимит на оценочную память по всем записям Shared.
SHARED_MAX_VALUE_SIZE 1 MiB Лимит сериализованного размера отдельного значения; большие значения бросают ValueTooLargeException.
SHARED_CYCLE_DETECT_DEPTH 16 Максимальная глубина BFS при проверке циклов. Поднимите для глубоких легитимных графов.
SHARED_CYCLE_DETECT_EDGES 10 000 Максимальное число рёбер при проверке циклов. Поднимите для плотных легитимных графов.
SHARED_PREVIEW_ARRAY_LIMIT 20 Число записей в выборке sample_keys в /entry?id=….
SHARED_INTROSPECTION_ENABLED true Включает/выключает API /__ox_shared/*.

Связанное

  • Shared\Counter — атомарное целое; храните внутри Map для подсчёта попаданий по каждому ключу.
  • Shared\Once — устойчивая к «набегу» ленивая инициализация, когда setIfAbsent заново запускал бы дорогую фабрику.
  • Shared\Channel — MPMC-очередь; дополняет, когда нужны FIFO-конвейеры, а не поиск по ключу.
  • Shared\Mutex — когда нужна строгая взаимная блокировка вокруг хранимого значения.