Shared\Registry

OxPHP\Shared\Registry — це іменований компаньйон решти OxPHP\Shared\*. Якщо new Shared\Map() створює анонімний запис, спільний лише завдяки поширенню дескриптора (захоплення через use, асинхронні файбери, вкладеність), то Registry::map('cache', fn() => new Shared\Map(...)) прив’язує запис до рядкового ключа. Кожен, хто викликає Registry::map('cache', …), у будь-якому потоці воркера й у будь-якому запиті, отримує той самий запис.

Він відповідає на одне запитання: «як зробити один Shared\Map спільним для всіх воркерів або для всіх запитів у традиційному режимі?» Решта типів Shared\* і далі залишаються правильною одиницею змінюваного стану; Registry — лише спосіб дати одному з них ім’я.

Ментальна модель

graph TD
  R["Registry::map('cache', $factory)"]
  W1["worker #1"] --> R
  W2["worker #2"] --> R
  W3["worker #3"] --> R
  R --> S["SharedRegistry (process-global)<br/>names: { 'cache' → Bound(Arc&lt;E&gt;) }<br/>entries: { id=7: Map { … } }"]
  • Перший, хто викликає Registry::map($key, $factory) для ще не прив’язаного ключа, запускає фабрику й закріплює отриманий запис під цим іменем.
  • Кожен наступний виклик (у тому самому потоці, в інших воркерах, у пізніших запитах) отримує той самий запис. Фабрика не запускається повторно; при влучанні її ігнорують.
  • Одночасні перші звернення блокуються на гейті для кожного ключа: рівно один потік запускає фабрику, решта чекають і отримують запис переможця. Це запобігає подвійному захопленню ресурсів для пулів з’єднань.

Ідентичність за іменем доповнює ідентичність за дескриптором. Анонімні (new Shared\*()) та іменовані записи співіснують в одному процесно-глобальному реєстрі. Індекс імен лише додає поверх нього пошук за рядком.

Швидкий старт — один лічильник для всіх воркерів

worker.php
<?php // worker.php — entry script in worker mode, executed once per worker thread require __DIR__ . '/vendor/autoload.php'; $requests = OxPHP\Shared\Registry::counter( 'request-counter', fn() => new OxPHP\Shared\Counter(), ); oxphp_worker(function () use ($requests) { $n = $requests->add(); // atomic across ALL workers — one shared int64 header('X-Request-Count: ' . $n); echo "hello\n"; });

Порівняйте з патерном захопленого дескриптора ($x = new Shared\Counter() у бутстрапі). Такий патерн дає окремий лічильник на кожен потік воркера: кожен воркер виконує власний бутстрап і отримує власний анонімний запис. Сумарні значення розходяться в стільки разів, скільки воркерів у пулі. Натомість Registry::counter('request-counter', …) зводить усі воркери до одного запису, тож лічильник показує справжню загальну кількість.

Та сама схема працює й у традиційному режимі (без WORKER_MODE_ENABLED). Перший запит, що звертається до 'request-counter', створює запис; кожен наступний запит (у будь-якому потоці воркера) його бачить. Це і є заміна APCu в межах одного хоста — з типізованими примітивами та атомарними операціями замість apcu_fetch / apcu_store.

Довідник API

php
namespace OxPHP\Shared; final class Registry { // Typed get-or-create. On hit, the factory is ignored; on miss it // runs at most once across all workers (block-losers) and must // return a fresh instance of the matching type. public static function map(string $key, callable $factory): Map; public static function counter(string $key, callable $factory): Counter; public static function atomic(string $key, callable $factory): Atomic; public static function flag(string $key, callable $factory): Flag; public static function once(string $key, callable $factory): Once; public static function mutex(string $key, callable $factory): Mutex; public static function channel(string $key, callable $factory): Channel; public static function pool(string $key, callable $factory): Pool; // Untyped escape hatch — returns whatever is bound (no type guard). public static function global(string $key, callable $factory): Shareable; // Namespace management — operates on the name index, NOT the objects. public static function remove(string $key): bool; public static function keys(): array; // list<string> // Layer-wide introspection. public static function memoryUsage(): int; // estimated bytes, all Shared\* entries public static function count(): int; // live Shared\* entries (named + anonymous) }
Метод Повертає Призначення
map / counter / atomic / flag / once / mutex / channel / pool запитаний тип Shared\* Основний інтерфейс. Тип перевіряється при влучанні та при поверненні з фабрики.
global Shareable Нетипізоване get-or-create. Вдавайтеся до нього лише тоді, коли справді не знаєте заздалегідь прив’язаного типу.
remove bool Скидає прив’язку імені та закріплення. Не знищує об’єкт.
keys list<string> Наразі прив’язані ключі (лише Bound; слоти Creating, що ще створюються, не входять).
memoryUsage int Оцінка байтів для всього процесу — див. Використання ресурсів та інтроспекція.
count int Живі записи в усьому процесі (іменовані та анонімні).

Registry — статичний фасад: new Registry() кидає Shared\SharedException.

Життєвий цикл — закріплено за замовчуванням

Прив’язаний ключ тримає сильне посилання на свій запис; запис живе весь час існування процесу, якщо ви явно не викличете remove(key) або процес не завершиться. Це зроблено навмисно: у традиційному режимі, де кожен запит створює власні дескриптори на боці PHP і вони зникають наприкінці запиту, закріплення в індексі імен — єдина причина, чому запис переживає проміжок між запитами.

Щоб інвалідувати вміст іменованого запису, змінюйте його на місці ($cache->clear(), $counter->set(0), $bucket->remove($k)), а не видаляйте ім’я. Зміни спільні за посиланням: кожен, хто тримає той самий ключ, бачить їх одразу.

remove — це керування простором імен, а не знищення об’єкта

remove($key) скидає прив’язку та закріплення. Сам запис існує, доки на нього посилається будь-який інший дескриптор (захоплена змінна бутстрапу, значення, вкладене в інший Shared\Map, незавершений oxphp_async). Коли зникає останній дескриптор, запис, як звичайно, сам знімається з реєстрації.

Після remove ключ вільний. Наступний Registry::map($key, …) створює новий запис з іншим id.

Warning

Захоплені дескриптори попередньої прив’язки й далі працюють зі старим (тепер анонімним) записом; вони не переходять автоматично на новий.

php
$cache = Registry::map('cache', fn() => new Shared\Map()); $id_a = $cache->id(); Registry::remove('cache'); $cache->set('x', 1); // still mutates the OLD entry — fine, but it's no longer "cache" $fresh = Registry::map('cache', fn() => new Shared\Map()); $id_b = $fresh->id(); // different id — this is a new entry assert($id_a !== $id_b); assert($cache->get('x') === 1); // OLD entry retained value assert($fresh->get('x') === null); // NEW entry is empty

Якщо ви ротуєте ключі (записи для окремих тенантів, що з’являються й зникають, версіонування ключів), звертайтеся до них за іменем під час кожного виклику (Registry::map($key, …) у кожному запиті), а не захоплюйте дескриптор один раз у бутстрапі. Захоплені дескриптори разом із ротацією ключів непомітно розходяться; звернення за іменем завжди сходиться до поточної прив’язки.

remove повертає true, якщо прив’язаний ключ було скинуто, і false, якщо ключа не було.

Помилки

Виняток Коли
Shared\TypeException Типізований метод викликано для ключа, прив’язаного до іншого типу; фабрика повернула не той тип Shared\* або не Shareable.
Shared\CapacityException Створення перевищило б ліміти SHARED_MAX_ENTRIES / SHARED_MAX_BYTES.
Shared\DeadlockException (повторний вхід) Registry::map($key, …) для того самого $key зсередини його власної фабрики в тому самому потоці.
Shared\DeadlockException (цикл між ключами) Очікування на слот Creating іншого потоку тривало понад 30 с — найімовірніше, фабрика A тримає ключ K1 і чекає на K2, фабрику якого тримає потік B, що чекає на K1. Повідомлення відрізняється від випадку повторного входу.
Shared\SharedException (завершення роботи) Сервер зупиняється — реєстр відмовляє в нових отриманнях і прив’язках. Очікувано під час коректного завершення; це не помилка в коді.
Shared\SharedException (перегони прив’язки) Інший творець уже зайняв слот, поки виконувалася фабрика цього потоку (запис фабрики НЕ було закріплено під ключем). Повторіть виклик.
\InvalidArgumentException (SPL) Порожній $key. Перевірка аргументів, окремо від доменних помилок типу.
(виняток фабрики) Якщо фабрика кидає виняток, слот скасовується (Creating → відсутній, ті, хто чекав, прокидаються для повторної спроби), а початковий виняток передається творцю. Фатальна помилка всередині фабрики так само скасовує слот і все одно завершує запит.

Shared\DeadlockException розширює OxPHP\Async\AsyncException, тож catch (AsyncException) перехоплює його разом із тайм-аутами обмеженого очікування в інших місцях Shared\*. Обидва випадки DeadlockException мають спільний клас; розрізняйте їх за повідомленням ("reentrant get-or-create" проти "waited too long … cross-key cycle").

Використання ресурсів та інтроспекція

Registry::memoryUsage() і Registry::count() звітують про весь шар Shared*, а не лише про іменовані записи. Анонімні записи, створені через new Shared\*() (основна частина нинішнього використання Shared\*: захоплення в бутстрапі, значення в дорозі всередині Map і Channel, захоплення в асинхронних файберах), також враховуються.

Це зроблено навмисно. Обидва числа існують для моніторингу ємності та OOM; такий моніторинг має бачити анонімний стан окремих воркерів і незавершених операцій, а не лише іменований простір. Як наслідок:

  • Обидва числа мінливі: вони зростають і спадають разом із поточними запитами та дескрипторами воркерів.
  • Registry::count() не дорівнює count(Registry::keys()). keys() — це лише іменований простір.
  • memoryUsage() — статична облікова оцінка, а не справжній RSS. Це те саме число, з яким порівнюється SHARED_MAX_BYTES під час створення Shared-об’єкта, і воно може перевищувати цей ліміт. Щоб дізнатися реальний обсяг купи, скористайтеся профілювальником купи (heaptrack, jemalloc_stats_print, mi_stats_print) або метриками пам’яті контейнера.

Деталі по кожному запису (id, тип, лічильник посилань, вартість у байтах) доступні на внутрішньому ендпоінті інтроспекції за адресою /__ox_shared/entries. PHP API для окремих записів навмисно немає, щоб не дублювати цей інтерфейс.

Коли не варто використовувати

  • Між процесами та хостами. Реєстр живе всередині одного процесу OxPHP. Кілька екземплярів OxPHP його не поділяють. Використовуйте Redis / NATS / ваш наявний брокер; див. Перехід на зовнішнє сховище.
  • Збереження після перезапусків. Реєстр зникає, коли процес завершується. Зберігайте дані в тому ж зовнішньому сховищі.
  • Ефемерні ключі з високою плинністю. Через закріплення за замовчуванням динамічні ключі, які ви генеруєте в кожному запиті, лишають записи, доки ви не викличете remove. Ліміти SHARED_MAX_* це обмежують, але так робити все одно погано. Для короткочасного стану в межах запиту використовуйте звичайну змінну PHP.
  • Як примітив інвалідації кешу. remove($key) призначений для виведення імені з ужитку, а не для «очищення кешу». Інвалідуйте вміст на місці ($map->clear(), $map->remove($member_key)); прив’язка імені зберігається.

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

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

prompt
Проведи аудит дескрипторів Shared\*, які створюються напряму всередині бутстрапу воркера, — так виходить окремий екземпляр на кожен потік воркера, а не один спільний запис. Прочитай сторінку `shared-state/shared-registry` через MCP-сервер документації OxPHP. Знайди їх у цьому репозиторії й покажи для кожного виклик `Registry::`, який дає йому ім’я, щоб усі воркери сходилися до того самого запису.

Дивіться також