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<E>) }<br/>entries: { id=7: Map { … } }"]
- Первый вызывающий
Registry::map($key, $factory)для непривязанного ключа выполняет фабрику и закрепляет полученную запись под этим именем. - Каждый последующий вызывающий (тот же поток, другие воркеры, более поздние запросы) получает ту же самую запись. Фабрика не запускается повторно; при попадании она игнорируется.
- Конкурентные первые обращения блокируются на барьере, привязанном к ключу: ровно один поток выполняет фабрику, остальные ждут и получают запись победителя. Это предотвращает повторный захват ресурсов для пулов соединений.
Идентичность по имени дополняет идентичность по дескриптору. Анонимные (new Shared\*()) и именованные записи сосуществуют в одном и том же глобальном для процесса реестре. Индекс имён добавляет поверх этого поиск по строке.
Быстрый старт — один счётчик на все воркеры
<?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
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.
Захваченные дескрипторы предыдущей привязки продолжают работать со старой (теперь уже анонимной) записью; они не сходятся автоматически на новой.
$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)); привязка имени сохраняется.
См. также
- Разделяемое состояние. Обзор слоя, идентичность по дескриптору и когда
new Shared\*()— правильный инструмент. - Shared\Map, Shared\Counter, Shared\Pool и другие типизированные примитивы, которые возвращает
Registry. - Наблюдаемость разделяемого состояния. JSON API
/__ox_shared/*и метрики Prometheus. - Миграция во внешнее хранилище. Когда вам становится тесно в одном процессе.