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
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
// 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
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
$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
// 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\Pool — Once даёт вам одно значение; Pool даёт N.
Быстрый отказ при нарушенном предусловии
<?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сохраняются и считываются обратно. Замыкания, ресурсы и не-ShareablePHP-объекты вызываютTypeException.
getOrInit() изнутри его собственной фабрики вызывает DeadlockException. Перестройте код так, чтобы внутренний вызов использовал другой Once.
Объект исключения 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).