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
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со значениемnull→TypeException.get/swap/pop/setIfAbsent, возвращающиеnull⟺ ключ отсутствовал.- В
compareAndSetзначениеnullс любой стороны означает «отсутствует» (а не «сохранить null»).
Если нужно зафиксировать «нет значения», удалите ключ (или используйте отсутствие ключа), а не сохраняйте null. Поскольку метода has() нет и нет гонки между конкурентными has()+get(), наличие проверяется атомарно одним get($k) !== null.
compareAndSet — условный примитив
$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 как явный цикл повторов — и держите замыкание чистым, поскольку при конкуренции оно выполняется более одного раза:
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
$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
$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
$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
$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
$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
$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
$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-переменных или разбейте граф.
Ограничение размера отдельного значения
Отдельное значение, чей сериализованный размер превышает SHARED_MAX_VALUE_SIZE (по умолчанию 1 MiB), отклоняется с ValueTooLargeException. Это защищает от «бомбы выделения памяти» из данных со стороны PHP. Применяется ко всем путям записи (set, setIfAbsent, swap, compareAndSet, setMany).
Пакетные операции атомарны по ключу, а не по пакету
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— когда нужна строгая взаимная блокировка вокруг хранимого значения.