Shared\Map

OxPHP\Shared\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; будь-яку операцію «прочитати — змінити — записати» будуйте на ньому.
  • Конкурентність. Записи з різних воркерів не потребують зовнішнього блокування; операції над окремим ключем атомарні на рівні шарда.
  • Безпека щодо циклів. Спробу зберегти Shareable, що вела б назад до цієї мапи, відхилено з 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 зі значенням null → TypeException.
  • 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 (like setIfAbsent, but 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 === у типових випадках (списки, масиви лише з цілими або лише з рядковими ключами); масиви, у яких перемежовуються цілі й рядкові ключі, порівнюються в нормалізованій формі зберігання мапи, тож конкретний порядок цілих/рядкових ключів не розрізняється (під час зчитування мапа так само переупорядковує такі масиви). Операцію «прочитати — змінити — записати» будуйте як явний цикл повторних спроб — і тримайте замикання чистим, бо за конкуренції воно виконується більше одного разу:

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

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

Модель пам’яті — де відбувається копіювання

Значення зберігаються в серіалізованому вигляді, а не як zval — тож «нуль копіювань» до значень не застосовується:

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

Серіалізація на шляху запису неминуча для будь-якого значення, що потрапляє у спільну пам’ять. Зворотне зчитування в новий zval оплачують лише методи, які повертають попереднє або знайдене значення, — тож set/remove означають «без матеріалізації результату», а не «безкоштовно». Виняток — вкладене значення Shareable: воно зберігається за посиланням (ідентифікатор + збільшення лічильника посилань), а не копіюється вглиб.

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

  • count() слабко узгоджений. Кількість записів ведеться окремо для кожного шарда й підсумовується під час читання; результат точний, коли мапа в спокої, і є близьким наближенням під час конкурентних записів (той самий контракт, що й у ConcurrentHashMap::size). Розподіл по шардах позбавляє записи одного «гарячого» лічильника.
  • maxEntries — м’який ліміт. Він перевіряється щодо суми по шардах, тож під час конкурентних вставлень мапа може перевищити його щонайбільше на кількість шардів, перш ніж відхилить новий ключ з CapacityException. Сприймайте його як бюджет для захисту від OOM, а не як точний облік. Перезапис наявного ключа на межі ліміту завжди успішний. Витіснення немає — кеш із витісненням 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();

Ітерація великої мапи

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 — збільште ці змінні середовища або розірвіть граф.

Ліміт розміру одного значення

Note

Значення, серіалізований розмір якого перевищує SHARED_MAX_VALUE_SIZE (типово 1 MiB), відхиляється з ValueTooLargeException. Це захищає від «бомби виділення пам’яті» через вхідні дані з боку PHP. Ліміт діє на всіх шляхах запису (set, setIfAbsent, swap, compareAndSet, setMany).

Пакетні операції атомарні для кожного ключа, але не для всього пакета

Warning

setMany, getMany і removeMany обробляють по одному ключу за раз. Якщо setMany на півдорозі натрапить на CapacityException, CycleException або ValueTooLargeException, раніше записані ключі залишаться збереженими — частковий успіх тут навмисний. Якщо потрібна семантика «все або нічого», оберніть роботу з мапою в Shared\Mutex.

Міграція зі старого інтерфейсу

Несумісна зміна

Це несумісний редизайн без жодних прошарків сумісності.

Було Стало
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; значення, яке не можна зберегти (об’єкт/замикання/ресурс); ключ, що не є int/string; maxEntries <= 0.
StaleHandleException Виклик методу на дескрипторі, запис якого в реєстрі було витіснено.

Спостережуваність

Кожна мапа видима через внутрішній 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] — обхід у ширину вихідних посилань Shareable; зручно після CycleException.

Prometheus надає метрики-гейджі для кожної мапи на /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, якщо без обмеження.

Налаштування

Змінна середовища Типово Дія
SHARED_MAX_ENTRIES 100 000 Глобальний ліміт кількості живих Shared-об’єктів (кожен Counter, Map, Channel, … рахується як один). Створення об’єкта понад ліміт завершується CapacityException. Ключі всередині Map до нього не враховуються; обмежуйте Map через maxEntries.
SHARED_MAX_BYTES 1 GiB Глобальний ліміт оціночного обсягу пам’яті всіх Shared-об’єктів; перевіряється лише під час створення об’єкта (CapacityException). Наявні об’єкти можуть рости й далі, тож сумарне значення може перевищувати ліміт.
SHARED_MAX_VALUE_SIZE 1 MiB Ліміт серіалізованого розміру одного значення; більші значення кидають ValueTooLargeException.
SHARED_CYCLE_DETECT_DEPTH 16 Максимальна глибина обходу в ширину під час перевірки циклів. Збільште для глибоких легітимних графів.
SHARED_CYCLE_DETECT_EDGES 10 000 Максимальна кількість ребер, що обходяться під час перевірки циклів. Збільште для щільних легітимних графів.
SHARED_PREVIEW_ARRAY_LIMIT 20 Кількість записів у вибірці sample_keys у /entry?id=….
SHARED_INTROSPECTION_ENABLED true Вмикає або вимикає API /__ox_shared/*.

Промпти для вашого агента

Ці промпти розраховані на агента з підключеним MCP-сервером документації OxPHP — потрібні сторінки він прочитає сам.

prompt
Використай OxPHP Shared Map у моєму коді замість кешу на рівні запиту, який є зараз. Прочитай сторінку `shared-state/shared-map` через MCP-сервер документації OxPHP. Знайди в цьому репозиторії місця, де дані справді спільні для всього процесу, а не прив’язані до запиту, і покажи мені зміну для одного з них від початку до кінця.

Пов’язані сторінки

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