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 参考
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\Mutex 和 Shared\Channel 相同的三分法:裸方法会无限等待,try* 方法是非阻塞的,而 *Timeout(int $ms) 方法会等待有限的毫秒数。不存在以浮点秒数表示的超时。
示例
数据库连接池
<?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
$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
$parsers = new OxPHP\Shared\Pool(
factory: fn () => new JsonMachine\Parser(),
maxSize: 8,
);
$doc = $parsers->with(fn ($p) => $p->parse($body));带回退的非阻塞获取
<?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
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 必须 > 0;0 或负数会引发 TypeException。这里刻意不提供浮点秒数形式,也不提供表示「无限」的哨兵参数——如需无限期等待,请使用裸的 acquire()。
异常
| 异常 | 抛出场景 |
|---|---|
OperationTimeoutException |
acquireTimeout / withTimeout 超过 $ms 仍无空闲槽位。继承自 Async\AsyncException,而非 SharedException。 |
TypeException |
非正数的 maxSize、负数的 idleTimeoutMs、$ms <= 0,或工厂返回了非对象。 |
StaleHandleException |
句柄已释放后又调用 Handle::get()。 |
UninitializedException |
在尚未完成 __construct 的池包装器上调用方法。 |
tryAcquire() 在饱和时不会抛出异常——它返回 null。由于 OperationTimeoutException 继承自 OxPHP\Async\AsyncException(而非 SharedException),catch (SharedException) 不会捕获获取超时;请使用 catch (OxPHP\Async\AsyncException),或直接捕获 OperationTimeoutException。
两者都是非阻塞的 try* 调用,但 Pool 在发生争用时返回 null,而 Mutex 抛出 ContentionException。这种区别是结构性的,而非风格上的。Pool 是句柄优先的:每一次获取都会交回一个 Handle,因此「饱和」这一结果有一个天然的载体——?Handle,其中 null 表示「没有槽位」,且绝不会与真实值冲突(Handle 本身永远不会是用户的值)。Mutex 在设计上是仅闭包的——它刻意从不把锁守卫交回给 PHP,因此已持有的锁不会泄漏到闭包之外。这就使得 tryWithLock 没有可作为可空返回值的对象,而闭包自身的 mixed 结果本身就可能合法地为 null——所以 null 无法兼作「未获取」之意。既没有句柄、也没有空闲的哨兵值,剩下唯一无歧义的争用信号就只有异常了。请据此进行捕获:tryAcquire → 检测是否为 null;tryWithLock → catch (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包含一个带有count、bytes和ops的Pool分桶。诸如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。 - 工作进程模式——在单个工作线程内跨请求持有池句柄。