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 参考
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——条件原语
$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 在回读时也会对这类数组重新排序)。请把读-改-写写成显式的重试循环——并保持闭包为纯函数,因为在竞争下它会运行不止一次:
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
$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
$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
$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
$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
$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
$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
$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——请调高这些环境变量旋钮,或者打破图中的循环。
单值大小上限
序列化后大小超过 SHARED_MAX_VALUE_SIZE(默认 1 MiB)的单个值会以 ValueTooLargeException 被拒绝。这可以防范来自 PHP 侧输入的分配炸弹。它适用于每一条写入路径(set、setIfAbsent、swap、compareAndSet、setMany)。
批量操作是单键原子的,而非整批原子的
setMany、getMany 和 removeMany 每次处理一个键。如果 setMany 在中途遇到 CapacityException、CycleException 或 ValueTooLargeException,此前的键仍会保留——这种部分成功是有意为之的。如果你需要全有或全无的语义,请在该映射外围使用 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_count、max_entries、saturation和sample_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——当你需要围绕某个存储的值进行严格的互斥时。