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
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⟺ ключа не було.- У
compareAndSetnullз будь-якого боку означає «відсутній» (а не «зберегти null»).
Якщо потрібно зафіксувати «немає значення», видаліть ключ (або скористайтеся відсутністю ключа), а не зберігайте null. Оскільки has() немає, а отже немає й гонки між конкурентними has() і get(), наявність перевіряється атомарно одним get($k) !== null.
compareAndSet — умовний примітив
$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 === у типових випадках (списки, масиви лише з цілими або лише з рядковими ключами); масиви, у яких перемежовуються цілі й рядкові ключі, порівнюються в нормалізованій формі зберігання мапи, тож конкретний порядок цілих/рядкових ключів не розрізняється (під час зчитування мапа так само переупорядковує такі масиви). Операцію «прочитати — змінити — записати» будуйте як явний цикл повторних спроб — і тримайте замикання чистим, бо за конкуренції воно виконується більше одного разу:
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
$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();Ітерація великої мапи
<?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 — збільште ці змінні середовища або розірвіть граф.
Ліміт розміру одного значення
Значення, серіалізований розмір якого перевищує SHARED_MAX_VALUE_SIZE (типово 1 MiB), відхиляється з ValueTooLargeException. Це захищає від «бомби виділення пам’яті» через вхідні дані з боку PHP. Ліміт діє на всіх шляхах запису (set, setIfAbsent, swap, compareAndSet, setMany).
Пакетні операції атомарні для кожного ключа, але не для всього пакета
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 — потрібні сторінки він прочитає сам.
Використай OxPHP Shared Map у моєму коді замість кешу на рівні запиту, який є зараз. Прочитай сторінку `shared-state/shared-map` через MCP-сервер документації OxPHP.
Знайди в цьому репозиторії місця, де дані справді спільні для всього процесу, а не прив’язані до запиту, і покажи мені зміну для одного з них від початку до кінця.Пов’язані сторінки
Shared\Counter— атомарне ціле число; зберігайте його в мапі для лічильників звернень за ключем.Shared\Once— лінива ініціалізація, стійка до «набігу», колиsetIfAbsentповторно запускав би дорогу фабрику.Shared\Channel— черга MPMC; доповнює мапу, коли потрібні FIFO-конвеєри, а не пошук за ключем.Shared\Mutex— коли потрібне суворе взаємне виключення навколо збереженого значення.