Shared\Once

OxPHP\Shared\Once 针对单个 Once 单元恰好运行一次初始化闭包,并使其结果对该单元的每一个后续调用方可见。它是用来实现"至多发生一次的昂贵操作"的基础原语。

若要获得真正的跨工作进程 / 跨请求的"整个进程范围内恰好一次"语义,请通过 Shared\Registry::once(...) 为该 Once 绑定一个名称,这样每个工作进程才会汇聚到同一个单元上。裸用 new Shared\Once() 构造函数的写法,在工作进程模式下会为每个工作进程线程各自生成一个单独的单元(每个工作进程的引导过程都会执行一次该构造函数),在传统模式下则是每个请求各自生成一个——这带来的是每个单元一次,而非每个进程一次。

概述

  • 跨工作进程只运行一次_(针对同一个单元)_。 两个工作进程同时抢着对同一个 Once 单元调用 getOrInit($factory) 时,工厂函数只会在其中一个上运行;落败者会阻塞并接收到胜出者的值。搭配 Shared\Registry::once 使用,就能让"同一个单元"意味着"每个工作进程上的同一个名称"。
  • 四状态机。 一个单元处于 UninitializedPending(此刻正有一个工厂函数在运行)、ReadyPoisoned 之一。用 status() 读取它。
  • 不存在含义模糊的 null。 对未设置的单元调用 get() 会抛出异常,而不是返回 null,因此存入的 null 是一个真实的值,而非"缺失"。
  • 可重入安全。 从某个 Once 的工厂函数内部对同一个 Once 调用 getOrInit() 会抛出 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 内省 / 诊断。绝不抛出异常(安全的 poison 观察者)。
trySet 是否胜出? 针对已在手的值的推模型初始化(不涉及有副作用的资源获取)。
getOrInit 存入的值 拉模型初始化;标准的无竞争原语。
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() 用于内省——它绝不会触发工厂函数,也绝不会抛出异常,即便面对一个已被 poison 的单元。若要真正无竞争地取得该值,请调用 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\Pool——Once 给你一个值;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());

语义与注意事项

  • 当单元不处于 Ready 时,get() 会抛出异常。 对空的或 Pending 的单元抛出 UninitializedException,对已被 poison 的单元抛出 PoisonedException。用 status() 来做无异常的分支,或用 getOrInit() 来安全地取得该值。
  • 每次成功的初始化,工厂函数至多运行一次。 并发的调用方会在胜出者上阻塞;它们不会各自运行一份副本。
  • 失败策略在构造时设定,而非逐次调用设定。 Reset(默认)在工厂函数失败时把单元恢复为 Uninitialized,以便后续调用重试;Poison 则让单元终局性地变为 Poisoned。在两种模式下,工厂函数的异常都会被重新抛给当前调用方。
  • 完整的值范围。 标量、数组以及嵌套的 Shareable 值都可以被存入并读回。闭包、资源以及非 Shareable 的 PHP 对象会引发 TypeException
重入会抛出异常

从某个工厂函数内部对其自身的 Once 调用 getOrInit() 会引发 DeadlockException。请重新组织代码,让内层调用使用另一个 Once

Poison 是跨线程忠实的,但并非对象同一的

一个 PHP 异常对象无法跨越工作进程线程,因此被 poison 的单元只捕获失败的类、消息和代码。任何线程上的后续调用方都会收到一个携带这些信息的全新 PoisonedException——细节相同,但不是同一个对象。

异常

异常 触发方
UninitializedException UninitializedPending 单元调用 get()
PoisonedException Poisoned 单元调用 get() / getOrInit() / trySet()
DeadlockException 从某个 Once 的工厂函数内部对同一个 Once 递归调用 getOrInit()
TypeException 存入的值不可序列化(闭包、资源)。
StaleHandleException 在其注册表条目已被逐出的句柄上调用的任何方法。

如果工厂函数自身抛出异常,该异常会原封不动地传播给当前调用方。在 Reset 模式下,单元保持未初始化状态,下一次 getOrInit 会重试;在 Poison 模式下,单元会变为已被 poison 的状态。

可观测性

参见 Shared 可观测性。快速参考:

  • GET /__ox_shared/entry?id=N 暴露 { status: "uninitialized" | "pending" | "ready" | "poisoned", type: "Once" },以及在 ready 时提供存入值的预览。

何时不该使用

  • 创建后会发生变化的值。 Once 是一次写入的。当存入的状态会发生变更时,请使用 Shared\MutexShared\Map
  • 每个工作进程各自的本地状态。 当值不需要被共享时,静态类属性或模块全局变量更廉价。
  • 昂贵的每请求计算。 请在请求内部做缓存,而不是放在共享状态里——否则你会泄漏内存。

相关内容

  • Shared State —— 概述与心智模型。
  • Shared\Mutex —— 当这个一次性的值稍后会发生变更时。
  • Shared\Pool —— N 个等价资源的一次性初始化。
  • Shared\Map —— 使用 getOrSet($key, $factory) 的按键初始化。