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["воркер #1"] --> R
  W2["воркер #2"] --> R
  W3["воркер #3"] --> R
  R --> S["SharedRegistry (глобальный для процесса)<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 (draining) Сервер завершает работу — реестр отклоняет новые захваты и привязки. Ожидаемо при корректном завершении работы; это не ошибка в коде.
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. Для реального объёма кучи используйте профилировщик кучи (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)); привязка имени сохраняется.

См. также