Shared\Map

OxPHP\Shared\Map 是一个存活在共享注册表中的并发映射,进程内的每个 PHP 工作进程都能看到它。当两个工作进程——或者一个请求处理器和一个后台任务——需要共享在请求生命周期结束后仍然存续的可变状态时,它就是首选的原语。

概述

  • int|string → mixed 键是 PHP 整数或字符串,二者保持区分123"123" 是不同的键;不存在 PHP 数组式的键强制转换)。字符串键是二进制安全的——以不透明字节的形式存储(类似 PHP 数组 / Go / Redis),因此非 UTF-8 的键(包括内嵌的 NUL)也能忠实往返。值可以是任意标量、由标量/数组构成的数组,或者另一个 Shareable 实例。
  • null 表示缺失——绝不作为存储的值。 写入 null 值会抛出 TypeException;返回 null 始终意味着“没有这个键”。这从根本上消除了经典的 get() 返回 null 的歧义(java.util.concurrent.ConcurrentHashMap 和 Go 的 sync.Map 做出了同样的选择)。
  • 单个可线性化的条件原语。 compareAndSet 通过 null 缺失哨兵值涵盖了原子的插入 / 替换 / 删除;任何读-改-写操作都可以在它之上构建。
  • 并发。 来自不同工作进程的写入无需外部加锁;单键操作在分片级别是原子的。
  • 循环安全。 存储一个会反向指回本 Map 的 Shareable,会在任何变更发生之前以 CycleException 被拒绝——被拒绝的路径上不会有泄漏。
  • 受近似软上限约束。 maxEntries 是一个防 OOM 的安全上限,而非精确计数。

API 参考

php
namespace OxPHP\Shared; final class Map implements Shareable { public function __construct(?int $maxEntries = null); // null = unbounded; <= 0 throws TypeException // reads public function get(int|string $key): mixed; // null ⟺ absent public function getMany(iterable $keys): \Iterator; // lazy; skips absent keys public function count(): int; // striped, weakly consistent public function maxEntries(): ?int; // writes public function set(int|string $key, mixed $value): void; public function setIfAbsent(int|string $key, mixed $value): mixed; // prev; null ⟺ inserted public function setMany(iterable $entries): int; public function remove(int|string $key): bool; // existed? public function removeMany(iterable $keys): int; public function clear(): int; // entries removed // value-returning public function swap(int|string $key, mixed $value): mixed; // prev; null ⟺ was absent public function pop(int|string $key): mixed; // prev; null ⟺ was absent // conditional (single linearisable primitive) public function compareAndSet(int|string $key, mixed $expected, mixed $new): bool; // iteration public function forEach(callable $fn): void; // weakly consistent; callback runs lock-free public function id(): int; }
方法 用途
__construct 使用可选的 maxEntries 上限创建(null = 无限制;<= 0 抛异常)。
get 按键获取;null ⟺ 缺失。
getMany 惰性地流式返回已知键的 key => value;缺失的键会被跳过(见下文)。
count 近似的条目数(并发写入下为弱一致)。
maxEntries 报告配置的上限(无限制时为 null)。
set 插入或替换;不物化旧值。
setIfAbsent 原子的“不存在则插入”;返回已有的值,若执行了插入则返回 null
setMany 从任意可迭代对象批量插入;返回写入的数量。
remove 删除一个键;返回它是否存在(不物化值)。
removeMany 批量删除;返回实际删除的数量。
clear 丢弃所有条目(释放对嵌套 Shareable 的持有);返回删除的数量。
swap 覆写并返回旧值(null ⟺ 此前缺失)。
pop 删除并返回旧值(null ⟺ 此前缺失)。
compareAndSet 基于当前内容进行原子的插入 / 替换 / 删除(见下文)。
forEach 弱一致的遍历;回调在不持有锁的情况下运行。
id 数字型的注册表标识符;用于日志记录与 /__ox_shared/entry?id=…

没有 has()update()getOrSet()keys()trySet()updateMany(),并且该类实现 Countable——参见从旧接口迁移

null 即缺失模型

null 在所有地方都被保留为缺失哨兵值:

  • set / swap / setIfAbsent 传入 null 值 → TypeException
  • get / swap / pop / setIfAbsent 返回 null ⟺ 该键此前缺失。
  • compareAndSet 中,任意一侧的 null 都表示“缺失”(而非“存储 null”)。

如果你需要记录“没有值”,请删除该键(或利用键的缺失),而不是存储 null。由于不存在 has(),也就没有并发的 has()+get() 竞态,用单次 get($k) !== null 就能原子地检查是否存在。

compareAndSet——条件原语

php
$map->compareAndSet($key, expected: null, new: $v); // insert iff absent (= setIfAbsent, returns bool) $map->compareAndSet($key, expected: $a, new: $b); // replace iff current === $a $map->compareAndSet($key, expected: $a, new: null); // remove iff current === $a

当且仅当交换被应用时它返回 true。相等性是按内容判定的:标量按值比较,字符串和数组按其序列化字节比较,嵌套的 Shareable 值按注册表身份比较。在常见情形下(列表、全整数键或全字符串键的数组),数组相等性与 PHP 的 === 一致;而交错使用整数键和字符串键的数组,会基于 Map 归一化后的存储形式进行比较,因此不会区分特定的整数/字符串顺序(Map 在回读时也会对这类数组重新排序)。请把读-改-写写成显式的重试循环——并保持闭包为纯函数,因为在竞争下它会运行不止一次:

php
do { $cur = $map->get('counter'); // null if absent $next = ($cur ?? 0) + 1; } while (!$map->compareAndSet('counter', $cur, $next));

这里不存在 ABA 问题:存储是内容寻址的(对于值存储而言,内容相等的值就是同一个值),而嵌套 Shareable 的身份使用单调递增、永不复用的注册表 id。对于防击穿的惰性初始化,请使用 Shared\Once;对于池化资源,请使用 Shared\Pool

内存模型——拷贝发生在哪里

值以序列化表示形式存储,而非以 zval 形式存储——因此“零拷贝”并不适用于值:

操作 序列化进共享堆 将旧值物化为 zval
set / setMany
remove / removeMany
setIfAbsent 仅当存在旧值时
swap / pop 是 / —
get / getMany (仅键)
compareAndSet 是($new

对于任何进入共享内存的值,写入路径上的序列化都是无法避免的。而回读为一个新 zval 的开销由那些返回旧值/查得值的方法承担——所以 set/remove 是“无返回值物化”,而非“免费”。例外是嵌套的 Shareable 值:它按引用存储(一个 id 加上一次引用计数递增),不会被深拷贝。

并发

  • count() 是弱一致的。 条目计数按分片分条(striped)存储,并在读取时求和;当映射处于静止状态时结果是精确的,在并发写入下则是相当接近的近似值(与 ConcurrentHashMap::size 的契约相同)。分条存储让写入不必争抢单个热点计数器。
  • maxEntries 是软上限。 它是对分条求和后的结果进行检查的,因此在并发插入下,映射在以 CapacityException 拒绝新键之前,可能最多超出分片数量那么多的条目。请把它当作防 OOM 的安全预算,而非精确计数。在达到上限时,覆写已有的键始终会成功。这里没有淘汰机制——带 LRU/TTL 淘汰的缓存是另一种不同的原语。
  • forEach 在不持有锁的情况下运行回调。 它每次快照一个分片的键,释放该分片,然后重新获取每个值并调用 $fn(key, value)。在快照与调用之间被删除的键会被跳过;在某个分片快照之后添加的键可能会被遗漏;值可能比快照时刻更新。从回调返回 false 可提前停止。由于只对键做快照,慢速回调绝不会把已删除的值固定住(pin)。

示例

共享配置缓存

php
<?php $config = new OxPHP\Shared\Map(maxEntries: 1024); // Warm once at app bootstrap. $config->setMany([ 'rate_limit.default_rpm' => 600, 'feature.new_checkout' => true, 'timeout.downstream_ms' => 250, ]); // Any request handler reads without contention; null ⟺ not configured. $rpm = $config->get('rate_limit.default_rpm') ?? 60;

按租户的限流器

php
<?php $buckets = new OxPHP\Shared\Map(maxEntries: 50_000); $key = "tenant:{$tenantId}"; $prev = $buckets->setIfAbsent($key, ['tokens' => 100, 'refill_at' => time() + 60]); // $prev === null ⟺ we created the bucket; otherwise it holds the existing one. $state = $buckets->get($key); if ($state['tokens'] === 0) { throw new RateLimitException(); }

跨工作进程协调计数器

php
<?php $counters = new OxPHP\Shared\Map(); $counters->set('requests_handled', new OxPHP\Shared\Counter()); // Any worker increments via the stored Shareable (stored by reference). $counters->get('requests_handled')->add();

迭代一个大型映射

php
<?php $sessions->forEach(function (int|string $key, mixed $value): bool|null { if ($value['expires_at'] < time()) { // safe: forEach holds no lock during the callback return null; // keep going } return null; }); // Or read a known subset lazily, stopping early: foreach ($cache->getMany($hotKeys) as $key => $value) { if (enoughCollected()) break; // remaining keys are never materialised handle($key, $value); }

语义与陷阱

数组在读取时被拷贝

php
<?php $m = new OxPHP\Shared\Map(); $m->set('cfg', ['timeout' => 5, 'retries' => 3]); $cfg = $m->get('cfg'); $cfg['timeout'] = 10; // mutates the returned copy only // $m->get('cfg')['timeout'] is still 5

要原子地更新一个数组值,请读取它、修改副本,然后用 compareAndSet 提交(冲突时重试),或者把各自独立变化的字段存为嵌套的 Shared\Counter / Shared\Map

嵌套 Shareable 的引用保持是自动的

php
<?php $map = new OxPHP\Shared\Map(); $counter = new OxPHP\Shared\Counter(10); $map->set('c', $counter); $retrieved = $map->get('c'); // same Shareable identity $retrieved->add(); // mutation visible via $counter too echo $counter->get(); // 11 $counter2 = $map->pop('c'); // Map releases its hold, returns the value $counter2->add(); // still alive via the returned wrapper

循环检测在变更之前拒绝

php
<?php $a = new OxPHP\Shared\Map(); $b = new OxPHP\Shared\Map(); $a->set('b', $b); // fine try { $b->set('a', $a); // closes the loop } catch (OxPHP\Shared\CycleException $e) { // message: "cycle would form: #… → #… (inserting into #…)" } $b->get('a'); // null — $b untouched, no leaked retains

数组内部的嵌套引用同样会被检查。遍历器受 SHARED_CYCLE_DETECT_DEPTH(默认 16)和 SHARED_CYCLE_DETECT_EDGES(默认 10 000)约束;非常大的图会以 bounds exceeded 抛出 CycleException——请调高这些环境变量旋钮,或者打破图中的循环。

单值大小上限

Note

序列化后大小超过 SHARED_MAX_VALUE_SIZE(默认 1 MiB)的单个值会以 ValueTooLargeException 被拒绝。这可以防范来自 PHP 侧输入的分配炸弹。它适用于每一条写入路径(setsetIfAbsentswapcompareAndSetsetMany)。

批量操作是单键原子的,而非整批原子的

Warning

setManygetManyremoveMany 每次处理一个键。如果 setMany 在中途遇到 CapacityExceptionCycleExceptionValueTooLargeException,此前的键仍会保留——这种部分成功是有意为之的。如果你需要全有或全无的语义,请在该映射外围使用 Shared\Mutex

从旧接口迁移

破坏性变更

这是一次破坏性的重新设计,没有任何兼容性垫片。

has($k) get($k) !== null(原子——没有 has/get 竞态)
get($k, $default) get($k) ?? $default
trySet($k, $v): bool setIfAbsent($k, $v): mixed(返回旧值;null ⟺ 已插入)
remove($k)(返回旧值) remove($k): bool,或用 pop($k) 取值
update($k, $fn) compareAndSet 重试循环,或用 Shared\Once 做一次性初始化
getOrSet($k, $fn) setIfAbsent,或视情况用 Shared\Once / Shared\Pool
updateMany(...) 一个 compareAndSet 循环
keys(): array forEach(...),或 getMany($knownKeys)
count($map)(Countable) $map->count()
存储一个 null 使用键缺失 / remove

异常

所有可能失败的方法都会抛出 OxPHP\Shared\SharedException 的子类:

异常 抛出场景
CapacityException 超过 maxEntries 后新增键(set / setIfAbsent / compareAndSet / setMany)。
ValueTooLargeException 超过单值上限(SHARED_MAX_VALUE_SIZE)的值。
CycleException 会形成可达性循环的写入(extends TypeException)。
TypeException null 值;不可存储的值(对象/闭包/资源);非整数/字符串的键;maxEntries <= 0
StaleHandleException 在注册表条目已被逐出的句柄上进行方法调用。

可观测性

每个 Map 都可以通过内部 API 查看:

  • GET /__ox_shared/summary——按类型汇总计数,包括 Map
  • GET /__ox_shared/entries——列出所有条目及其 id / type / refcount / mem_bytes。
  • GET /__ox_shared/entry?id=N——Map 的单实例详情包括 key_countmax_entriessaturationsample_keys(受预览上限截断)。
  • GET /__ox_shared/graph?id=N[&depth=D][&edges=E]——对外向 Shareable 引用进行 BFS 遍历;在 CycleException 之后很有用。

Prometheus 在 /metrics 处暴露每个 Map 的仪表盘指标(gauge):

指标 含义
oxphp_shared_map_entries{map_id="…"} 当前(近似的)键数量。
oxphp_shared_map_max_entries{map_id="…"} 配置的上限(无限制时为 0)。
oxphp_shared_map_saturation{map_id="…"} entries / max_entries,无限制时为 0。

配置

环境变量 默认值 作用
SHARED_MAX_ENTRIES 100 000 所有 Shared 条目合计的全局上限。
SHARED_MAX_BYTES 1 GiB 所有 Shared 条目估算内存的全局上限。
SHARED_MAX_VALUE_SIZE 1 MiB 单值序列化大小上限;更大的值会抛出 ValueTooLargeException
SHARED_CYCLE_DETECT_DEPTH 16 循环检查时的最大 BFS 深度。对于确实很深的合法图可调高。
SHARED_CYCLE_DETECT_EDGES 10 000 循环检查时遍历的最大边数。对于确实稠密的合法图可调高。
SHARED_PREVIEW_ARRAY_LIMIT 20 /entry?id=…sample_keys 中采样的条目数量。
SHARED_INTROSPECTION_ENABLED true 开关 /__ox_shared/* API。

相关

  • Shared\Counter——原子整数;存入 Map 中以实现按键的命中计数。
  • Shared\Once——当 setIfAbsent 会重复运行一个昂贵的工厂时,提供防击穿的惰性初始化。
  • Shared\Channel——MPMC 队列;当你需要 FIFO 管道而非按键查找时作为补充。
  • Shared\Mutex——当你需要围绕某个存储的值进行严格的互斥时。