Shared\Once

OxPHP\Shared\Once выполняет инициализирующее замыкание ровно один раз для одной ячейки Once и делает её результат видимым для каждого последующего обращения к той же ячейке. Это примитив для «дорогой операции, которая должна выполниться не более одного раза».

Чтобы получить настоящую семантику «ровно один раз в пределах всего процесса» между воркерами и между запросами, привяжите Once к имени через Shared\Registry::once(...), тогда все воркеры сойдутся на одной и той же ячейке. Простой конструктор new Shared\Once() создаёт отдельную ячейку на каждый поток воркера в режиме воркеров (bootstrap каждого воркера выполняет конструктор) и на каждый запрос в традиционном режиме — это даёт «один раз на ячейку», а не «один раз на процесс».

Обзор

  • Однократный запуск между воркерами для одной ячейки. Из двух воркеров, одновременно входящих в getOrInit($factory) на одной и той же ячейке Once, фабрику выполнит только один; проигравший блокируется и получает значение победителя. Скомбинируйте с Shared\Registry::once, чтобы «та же ячейка» означала «то же имя во всех воркерах».
  • Автомат с четырьмя состояниями. Ячейка может быть Uninitialized, Pending (фабрика выполняется прямо сейчас), Ready или Poisoned. Прочитать состояние можно через status().
  • Никакого неоднозначного null. get() бросает исключение на неустановленной ячейке вместо того, чтобы вернуть null, поэтому сохранённый null — это настоящее значение, а не «отсутствие».
  • Безопасность при повторном входе. Вызов getOrInit() на том же Once изнутри его собственной фабрики бросает DeadlockException, а не зависает.
  • Настраиваемая политика при сбоях. По умолчанию упавшая фабрика сбрасывает ячейку, чтобы последующий вызов мог повторить попытку. Включите Poison, чтобы упавшая фабрика навсегда отключала ячейку.
  • Пригодность к разделению. Экземпляры живут в реестре и передаются через захваты use и записи Shared\Map.

Справочник по API

php
namespace OxPHP\Shared; final class Once implements Shareable { public function __construct(Once\FailureMode $onFactoryError = Once\FailureMode::Reset); public function get(): mixed; // throws if not Ready public function status(): Once\Status; // never throws public function trySet(mixed $value): bool; // true if this call won public function getOrInit(callable $factory): mixed; // runs factory at most once public function id(): int; } namespace OxPHP\Shared\Once; enum Status { case Uninitialized; case Pending; case Ready; case Poisoned; } enum FailureMode: int { case Reset = 0; case Poison = 1; }
Метод Возвращает Сценарий использования
get сохранённое значение Чтение значения, о котором вы знаете, что оно Ready. Бросает исключение при uninit / pending / poison.
status Once\Status Интроспекция / диагностика. Никогда не бросает исключений (безопасный наблюдатель для отравленной ячейки).
trySet победитель? Инициализация по push-модели для значения, которое уже под рукой (не ресурс с побочными эффектами).
getOrInit сохранённое значение Инициализация по pull-модели; канонический примитив без гонок.
id id в реестре Корреляция для логирования / наблюдаемости.

Примеры

Дорогая конфигурация, загружаемая один раз на процесс

php
<?php // Registry::once binds the cell under a name so every worker's bootstrap // converges on it. Without Registry the bare `new Once()` here would // create one cell PER worker thread, and the factory would run once // per worker, not once per process. $config = OxPHP\Shared\Registry::once( 'app-config', fn() => new OxPHP\Shared\Once(), ); oxphp_worker(function () use ($config) { $cfg = $config->getOrInit(function () { // Runs in exactly one worker process-wide; every other worker // (and every later request, in traditional mode) blocks here // and sees the result. return json_decode(file_get_contents('/etc/myapp.json'), true); }); echo $cfg['greeting']; });

getOrInit() — это паттерн, устойчивый к cache stampede: при всплеске одновременных первых обращений фабрика выполняется ровно один раз при успехе, и каждый вызывающий — включая тех, кто проиграл гонку, — получает значение победителя. Если победившая фабрика бросает исключение в режиме Reset, следующий заблокированный вызывающий становится инициализатором и повторяет попытку, поэтому постоянно падающая фабрика под нагрузкой повторяет попытки последовательно, а не разворачивается в параллель. Используйте режим Poison (см. ниже), когда сбой должен быть терминальным.

Ветвление по состоянию без запуска инициализации

php
<?php use OxPHP\Shared\Once\Status; $cfg = new OxPHP\Shared\Once(); $report = match ($cfg->status()) { Status::Ready => $cfg->get(), Status::Pending => 'initialising…', Status::Uninitialized => 'not started', Status::Poisoned => 'config load failed', };

status() предназначен для интроспекции — он никогда не запускает фабрику и никогда не бросает исключений, даже на отравленной ячейке. Чтобы действительно получить значение без гонок, вызовите getOrInit().

Инициализация «сначала значение», когда значение уже известно

php
<?php $buildSha = new OxPHP\Shared\Once(); // A plain value with no acquisition side effects — trySet is fine here. $buildSha->trySet(getenv('GIT_SHA') ?: 'unknown'); $sha = $buildSha->get(); // Ready after the trySet above

Используйте trySet() только для значений, получение которых не имеет побочных эффектов. Для ресурсов (соединения, файловые дескрипторы, сокеты) используйте getOrInit(): trySet(), проигравший гонку, просто отдаёт обычное значение сборщику мусора, а вот ресурс, полученный до проигранной гонки, привёл бы к утечке.

Возврат false означает, что ячейка уже была Ready или Pending — это не гарантирует, что последующий get() завершится успешно, потому что Pending-фабрика в другом потоке всё ещё может упасть и сбросить ячейку (в режиме Reset). Не пишите if (!$o->trySet($v)) { $x = $o->get(); }; если вам нужно значение, вызывайте getOrInit().

Инициализация соединения с базой данных

php
<?php // Name the cell so only one PDO connection is opened across the // entire OxPHP process. The factory acquires a resource — exactly // what `getOrInit`'s block-losers semantics protect. $pool = OxPHP\Shared\Registry::once('db-conn', fn() => new OxPHP\Shared\Once()); $conn = $pool->getOrInit(function () { return new PDO(getenv('DB_DSN'), getenv('DB_USER'), getenv('DB_PASS'), [ PDO::ATTR_PERSISTENT => true, ]); });

Для пула соединений с несколькими слотами см. Shared\PoolOnce даёт вам одно значение; Pool даёт N.

Быстрый отказ при нарушенном предусловии

php
<?php use OxPHP\Shared\Once\FailureMode; // If this initialisation fails, the app cannot recover — poison the cell so // every later access fails loudly instead of retrying a doomed factory. $secrets = new OxPHP\Shared\Once(onFactoryError: FailureMode::Poison); $secrets->getOrInit(fn () => loadSecretsOrThrow());

Семантика и подводные камни

  • get() бросает исключение, когда ячейка не Ready. UninitializedException для пустой или Pending-ячейки, PoisonedException для отравленной. Используйте status(), чтобы ветвиться без исключения, или getOrInit(), чтобы безопасно получить значение.
  • Фабрика выполняется не более одного раза на успешную инициализацию. Одновременные вызывающие блокируются на победителе; они не запускают собственную копию.
  • Политика при сбоях задаётся при конструировании, а не для каждого вызова. Reset (по умолчанию) возвращает ячейку в состояние Uninitialized при сбое фабрики, чтобы последующий вызов повторил попытку; Poison делает ячейку окончательно Poisoned. В обоих режимах исключение фабрики повторно бросается текущему вызывающему.
  • Полный диапазон значений. Скаляры, массивы и вложенные значения Shareable сохраняются и считываются обратно. Замыкания, ресурсы и не-Shareable PHP-объекты вызывают TypeException.
Повторный вход бросает исключение

getOrInit() изнутри его собственной фабрики вызывает DeadlockException. Перестройте код так, чтобы внутренний вызов использовал другой Once.

Poison честен между потоками, но не сохраняет тождество объекта

Объект исключения PHP не может пересекать границы потоков воркеров, поэтому отравленная ячейка захватывает класс, сообщение и код сбоя. Последующие вызывающие в любом потоке получают новый PoisonedException, несущий эту информацию, — те же детали, но не тот же объект.

Исключения

Исключение Бросается
UninitializedException get() на ячейке Uninitialized или Pending.
PoisonedException get() / getOrInit() / trySet() на отравленной ячейке Poisoned.
DeadlockException getOrInit(), вызванный рекурсивно на том же Once из его фабрики.
TypeException Сохраняемое значение несериализуемо (замыкание, ресурс).
StaleHandleException Любой метод на дескрипторе, чья запись в реестре была вытеснена.

Если фабрика сама бросает исключение, оно без изменений распространяется на текущего вызывающего. В режиме Reset ячейка остаётся неинициализированной, и следующий getOrInit повторяет попытку; в режиме Poison ячейка становится отравленной.

Наблюдаемость

См. Shared Observability. Краткая справка:

  • GET /__ox_shared/entry?id=N возвращает { status: "uninitialized" | "pending" | "ready" | "poisoned", type: "Once" } плюс превью сохранённого значения, когда ready.

Когда не использовать

  • Значения, которые меняются после создания. Once — это запись один раз. Используйте Shared\Mutex или Shared\Map, когда сохранённое состояние изменяется.
  • Локальное состояние на каждый воркер. Статические свойства классов или глобальные переменные модуля дешевле, когда значение не нужно разделять.
  • Дорогие вычисления на каждый запрос. Кэшируйте внутри запроса, а не в разделяемом состоянии — иначе вы получите утечку памяти.

См. также

  • Shared State — обзор и ментальная модель.
  • Shared\Mutex — когда однократное значение впоследствии изменяется.
  • Shared\Pool — однократная инициализация N эквивалентных ресурсов.
  • Shared\Map — инициализация по ключу через getOrSet($key, $factory).