Shared\Pool

OxPHP\Shared\Pool 是一个有界的每线程资源池。它是用于管理这类对象的原语:创建成本高昂、无法廉价地重新创建、且不应无限量存在——通常是数据库连接、预处理语句缓存、可复用的 JSON 解码器或 HTTP 客户端会话。

Pool 为每个 PHP 工作线程提供一条属于自己的、随时可用的资源通道,在整个池范围内强制执行严格的上限,并自动回收空闲槽位,这样你就不必为用不到的容量付出代价。

概述

  • 严格的预算。 maxSize 是整个池的硬性上限。当池饱和时,一次获取要么等待、要么返回 null、要么抛出异常——具体取决于你调用的是哪个获取方法。
  • 每线程亲和性。 每个工作线程都有自己的空闲队列。获取操作会优先从本地队列取用,绝不会把在线程 A 上创建的槽位交给线程 B(v1)。
  • 工厂在发起获取的工作线程中运行。 资源是在每个线程首次需要时惰性创建的,而非在池构造时创建。
  • 驱逐时的销毁回调。 当池丢弃某个槽位时(空闲超时、手动驱逐、服务器停止),会运行一个可选的 destroy($resource) 闭包。
  • 空闲超时驱逐。 空闲时间超过 idleTimeoutMs 的槽位会被后台任务销毁。设置 idleTimeoutMs: 0 可完全禁用空闲驱逐。
  • RAII 句柄。 acquire() 返回一个 Handle;当句柄离开作用域时(包括发生异常时),槽位会自动归还到池中,也可以通过 $handle->release() 提前归还。
  • 可共享。 池能够跨越请求边界存续,并通过句柄共享(在闭包中使用 use ($pool))。

API 参考

php
namespace OxPHP\Shared; final class Pool implements Shareable { public function __construct( callable $factory, // fn(): object — create a resource ?callable $destroy = null, // fn(object): void — tear down a resource int $maxSize = 32, // hard cap on live slots; > 0 int $idleTimeoutMs = 300_000, // idle ms before eviction; 0 disables it ); // acquire family — millisecond timeout trichotomy public function acquire(): Pool\Handle; // wait forever public function tryAcquire(): ?Pool\Handle; // non-blocking; null if saturated public function acquireTimeout(int $ms): Pool\Handle; // bounded; $ms > 0 // with family — scope-guard around the raw resource public function with(callable $body): mixed; // wait forever public function withTimeout(callable $body, int $ms): mixed; // bounded; $ms > 0 public function stats(): Pool\Stats; // point-in-time snapshot of counters public function evict(): int; // force-evict all idle slots now; returns count public function id(): int; } namespace OxPHP\Shared\Pool; class Handle { public function get(): mixed; // the underlying resource (throws after release) public function release(): void; // return the slot now; idempotent; also runs on destruct } final class Stats { public function inUse(): int; // slots currently checked out public function idle(): int; // free slots ready to hand out public function waiting(): int; // callers blocked in acquire public function size(): int; // inUse() + idle() (live slots) public function maxSize(): int; // configured cap public function utilization(): float; // inUse() / maxSize(), 0.0 if maxSize() == 0 }
方法 返回值 用途
acquire Handle 取出一个资源,会一直等待直到有空闲槽位。
tryAcquire ?Handle 非阻塞取出。如果池已饱和,立即返回 null
acquireTimeout Handle $ms> 0)的有限预算内取出;超时则抛出 OperationTimeoutException
with mixed 作用域守卫:获取(无限等待),以原始资源运行 $body($resource),即便发生异常也会释放。闭包的返回值会被透传出来。
withTimeout mixed with 类似,但获取受 $ms 限制。
stats Pool\Stats 池计数器的某一时刻快照。
evict int 立即强制驱逐所有空闲槽位(无论 idleTimeoutMs 如何设置);返回丢弃的数量。
id int 注册表标识符;可用于日志记录/可观测性。
Handle::get mixed 底层资源。在释放后调用会抛出 StaleHandleException
Handle::release void 立即将槽位归还到池中。幂等;也会在析构时自动运行(RAII)。

超时机制遵循与 Shared\MutexShared\Channel 相同的三分法:裸方法会无限等待,try* 方法是非阻塞的,而 *Timeout(int $ms) 方法会等待有限的毫秒数。不存在以浮点秒数表示的超时。

示例

数据库连接池

php
<?php $db = new OxPHP\Shared\Pool( factory: function () { return new PDO( getenv('DB_DSN'), getenv('DB_USER'), getenv('DB_PASS'), [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION], ); }, destroy: function (PDO $conn) { // Nothing to do — PDO closes on destruct. The callback exists // for resources that need explicit teardown (sockets, handles). }, maxSize: 16, idleTimeoutMs: 60_000, // free idle connections after 1 min ); // In a request handler $users = $db->with(function (PDO $conn) use ($userId) { $stmt = $conn->prepare('SELECT * FROM users WHERE id = ?'); $stmt->execute([$userId]); return $stmt->fetch(); });

with() 是生命周期最短的模式:获取发生在进入时,释放发生在返回时异常时——你无法泄漏句柄。它还会把原始资源直接交给你的闭包,因此你可以省去 Handle::get() 这一步。

手动获取/释放

php
<?php $h = $pool->acquire(); // waits forever for a free slot $conn = $h->get(); $conn->beginTransaction(); doWork($conn); $conn->commit(); $h->release(); // or just let $h fall out of scope (RAII)

句柄在被销毁时会自动归还其槽位——包括异常展开调用栈时——因此显式的 release() 是可选的。只有当资源必须在处理器序列中的多次调用之间存续时,才应选用手动句柄(而非 with())。

可复用的解析器池

php
<?php $parsers = new OxPHP\Shared\Pool( factory: fn () => new JsonMachine\Parser(), maxSize: 8, ); $doc = $parsers->with(fn ($p) => $p->parse($body));

带回退的非阻塞获取

php
<?php $h = $pool->tryAcquire(); if ($h === null) { // Pool saturated — degrade gracefully without waiting. http_response_code(503); header('Retry-After: 1'); return; } // ... use $h->get(); slot returns on scope exit.

带超时的有限获取

php
<?php try { $h = $pool->acquireTimeout(100); // wait up to 100 ms } catch (OxPHP\Shared\OperationTimeoutException $e) { http_response_code(503); header('Retry-After: 1'); return; } // ... use $h->get();

工厂与销毁语义

工厂会在发起获取的工作线程上惰性运行。一个 maxSize: 32 的池不会预先分配 32 个资源;它会随着需求到来而创建资源,并受所有线程合计的 maxSize 上限约束。

  • 工厂必须返回一个 PHP 对象。返回非对象会在获取调用处表现为 TypeException,并且该槽位不会计入预算。
  • 抛出异常的工厂会将其自身的异常原样传播给获取方,并且该槽位不会计入预算。
  • 销毁回调(如果提供了的话)会在池丢弃某个槽位时运行:空闲超时到期、显式调用 evict() 或服务器关闭。它在工作线程上运行(而不是在驱动驱逐调度器的 Tokio 线程上),因此调用 PHP 是安全的。
  • 抛出异常的销毁回调会被记录到日志,但不会污染池——该槽位已经在被销毁了,因此没有什么值得回滚的。

每线程亲和性

v1 的池严格按线程隔离:在工作线程 A 上创建的槽位无法在工作线程 B 上获取。这在实践中意味着,当工作线程 B 正阻塞在 acquire() 中时,工作线程 A 上的 stats()->idle() 仍可能非零。这样能让槽位在使用它们的线程中保持热态(数据库连接、经 OPcache 预热的对象),并避免在各个核心之间来回搬运资源。

在每线程亲和性下的容量规划

跨线程的工作窃取是 v1.x 的候选特性。在此之前,请按照工作线程数量 × 每线程预期并发数来设定 maxSize,而不仅仅是按聚合需求。

空闲超时驱逐

空闲槽位由后台调度器驱逐。当某个槽位的空闲时间超过 idleTimeoutMs 时,调度器会给它打上标记;持有它的工作线程会在下一次请求时将其销毁(此时 PHP 引擎处于活动状态,因此 $destroy 会在正常的请求上下文中运行)。预算也在同一时刻释放。

请根据重新创建的成本来调整 idleTimeoutMs

  • 重新创建成本低廉(JSON 解码器、字符串池):设为 10_000–60_000 ms;在流量减退时快速释放内存。
  • 重新创建成本高昂(数据库连接、TLS 会话):设为 300_000 ms(默认)到 900_000 ms;更少地承担重新创建的成本。
  • 从不驱逐:传入 0。此时空闲槽位会一直存续,直到池被丢弃。

$pool->evict() 会立即强制驱逐当前调用方工作线程可触及的所有空闲槽位——无论 idleTimeoutMs 如何设置——并返回丢弃了多少个。它是运维层面「立即刷新空闲资源」的应急出口(例如某个下游服务重启了,而你希望下一次获取能创建全新的资源)。使用中的槽位不受影响。

预算与获取语义

每种获取变体都会先尝试立即满足请求——复用一个空闲槽位,或者(如果池尚未达到 maxSize)通过工厂创建一个新槽位。只有当池饱和时——既没有空闲槽位已达到 maxSize——行为才会有所不同:

调用时的状态 acquire() tryAcquire() acquireTimeout($ms)
本地线程队列中有空闲槽位 立即复用 立即复用 立即复用
没有空闲槽位,但尚未达到 maxSize 工厂创建一个槽位 工厂创建一个槽位 工厂创建一个槽位
已饱和(达到 maxSize,全部在用) 无限等待 返回 null 最多等待 $ms,然后抛出 OperationTimeoutException
没有浮点秒数,也没有无限哨兵值

$ms 必须 > 00 或负数会引发 TypeException。这里刻意不提供浮点秒数形式,也不提供表示「无限」的哨兵参数——如需无限期等待,请使用裸的 acquire()

异常

异常 抛出场景
OperationTimeoutException acquireTimeout / withTimeout 超过 $ms 仍无空闲槽位。继承自 Async\AsyncException,而非 SharedException
TypeException 非正数的 maxSize、负数的 idleTimeoutMs$ms <= 0,或工厂返回了非对象。
StaleHandleException 句柄已释放后又调用 Handle::get()
UninitializedException 在尚未完成 __construct 的池包装器上调用方法。
获取超时不是 SharedException

tryAcquire() 在饱和时不会抛出异常——它返回 null。由于 OperationTimeoutException 继承自 OxPHP\Async\AsyncException(而非 SharedException),catch (SharedException) 不会捕获获取超时;请使用 catch (OxPHP\Async\AsyncException),或直接捕获 OperationTimeoutException

为什么这与 Mutex::tryWithLock() 不同

两者都是非阻塞的 try* 调用,但 Pool 在发生争用时返回 null,而 Mutex 抛出 ContentionException。这种区别是结构性的,而非风格上的。Pool句柄优先的:每一次获取都会交回一个 Handle,因此「饱和」这一结果有一个天然的载体——?Handle,其中 null 表示「没有槽位」,且绝不会与真实值冲突(Handle 本身永远不会是用户的值)。Mutex 在设计上是仅闭包的——它刻意从不把锁守卫交回给 PHP,因此已持有的锁不会泄漏到闭包之外。这就使得 tryWithLock 没有可作为可空返回值的对象,而闭包自身的 mixed 结果本身就可能合法地为 null——所以 null 无法兼作「未获取」之意。既没有句柄、也没有空闲的哨兵值,剩下唯一无歧义的争用信号就只有异常了。请据此进行捕获:tryAcquire → 检测是否为 nulltryWithLockcatch (ContentionException)

在工厂内部抛出的异常会原样传播给获取方,且不会消耗预算。with() / withTimeout() 主体内部的异常会在槽位被释放之后传播给调用方。

可观测性

完整介绍请参见 共享可观测性。快速参考:

  • GET /__ox_shared/entry?id=N 暴露 { type: "Pool", size, in_use, idle, waiting, max_size, idle_by_thread, rebalance_strategy }
  • GET /__ox_shared/summary 包含一个带有 countbytesopsPool 分桶。诸如 waiting 这样的单池量规以及 evicted_total 计数器暴露在 /metrics(见下文)上,而不会汇总到该摘要中。
  • 每个池的 Prometheus 指标:
    • oxphp_shared_pool_size{pool_id="…"} — 量规,槽位总数(使用中 + 空闲)。
    • oxphp_shared_pool_in_use{pool_id="…"} — 量规。
    • oxphp_shared_pool_idle{pool_id="…"} — 量规。
    • oxphp_shared_pool_waiting{pool_id="…"} — 量规,排队中的获取。
    • oxphp_shared_pool_acquire_total{pool_id="…",result="ok|timeout|closed|saturated"} — 计数器。saturated 统计发现池已满的非阻塞 tryAcquire 调用(区别于 timeout,后者表示等待已耗尽)。
    • oxphp_shared_pool_evicted_total{pool_id="…",reason="idle_timeout|evict|shutdown"} — 计数器。
    • oxphp_shared_pool_wait_seconds_*{pool_id="…"} — 获取等待直方图(bucket / sum / count)。

值得告警的组合:waiting 上升而 size 保持平稳,意味着池已饱和、应当扩容;acquire_total{result="timeout"} 上升而 in_use 正常,意味着工厂很慢(或存在阻塞);acquire_total{result="saturated"} 上升,意味着调用方不断在满池上调用 tryAcquire(背压正在触发)。

何时不该使用

  • 廉价或不可变的资源。 池的开销比重新创建一个简单对象还要大。请把它用于那些创建耗费毫秒级时间或千字节级内存的资源。
  • 无法安全复用的对象。 如果资源会累积每请求的状态(未提交的事务、待处理的读取),而你又无法可靠地重置它,那么池化会在请求之间泄漏状态。请在请求收尾代码中把槽位恢复到已知状态,否则就不要池化。
  • 跨主机的资源。 池是进程内的。对于跨多主机的连接池,请优先选用连接分桶服务或 sidecar(pgbouncer、proxy-sql)。
  • 无界扇出。 如果你需要为每一个进行中的 HTTP 调用都配一个连接,那就不是池了——而是一个「每请求 N 个」的问题。请改用 Shared\Channel,把工作串行化到一个有界池之后。
  • 自带池语义的资源。 许多客户端库内部已经做了池化(例如 Guzzle 的连接池)。在其之上再叠一层 Shared\Pool 属于重复记账;请优先使用库自身的池化。

相关内容

  • 共享状态——概览与心智模型。
  • Shared\Once——当你只需要恰好一个资源(而不是 N 个资源的池)时。
  • Shared\Channel——与池搭配用于生产者/消费者流水线。
  • Shared\Map——以名称为键,每个租户一个 Pool
  • 工作进程模式——在单个工作线程内跨请求持有池句柄。