Shared\Mutex
OxPHP\Shared\Mutex 是一个进程范围的互斥锁,它包裹着一个存储值。你永远不会直接操作这把锁。相反,你把一个闭包交给三种方法变体之一,运行时会在闭包执行期间持有锁,并且即便闭包抛出异常也会释放锁。
概述
- 守护的是一个值,而不仅仅是一段临界区。 被包裹的值以引用方式传入你的闭包,因此当闭包正常返回时,闭包内部的直接修改会提交回去。
- 三种明确的等待策略,而不是一个身兼多职的
?float $timeout:withLock($fn)—— 永久阻塞(或直到请求纤程被取消)。tryWithLock($fn)—— 非阻塞;若锁已被持有则抛出ContentionException。withLockTimeout($fn, int $ms)—— 有界等待;超过截止时间抛出OperationTimeoutException。
- PHP 异常会自由传播。 如果你的闭包抛出一个普通的 PHP 异常,锁会释放,异常会向上冒泡。这把互斥锁不会被破坏——部分修改是可以接受的;调用方负责恢复不变式。
- Rust panic 会破坏互斥锁。 如果一个 Rust panic 越过了 FFI 边界(一个服务器缺陷),互斥锁会进入一个粘滞的损坏状态,此后每一次获取锁都会抛出
CorruptedMutexException。没有任何恢复 API——丢弃该实例并创建一个新的。 - 避免死锁。 在同一线程上重新进入同一把互斥锁(包括通过在本线程上捕获的嵌套异步调用)会抛出
DeadlockException,而不是挂起。
API 参考
namespace OxPHP\Shared;
final class Mutex implements Shareable
{
public function __construct(mixed $initial = null);
public function withLock(callable $fn): mixed;
public function tryWithLock(callable $fn): mixed;
public function withLockTimeout(callable $fn, int $ms): mixed;
public function id(): int;
}闭包签名是 function (mixed &$value): mixed —— $value 以引用方式传入,因此你可以就地修改它。闭包的正常返回值会转发给 withLock / tryWithLock / withLockTimeout 的调用方。返回路径支持标量、null 以及 Shared\* 实例(字符串、int、float、bool、字节串、null,以及任何实现了 OxPHP\Shared\Shareable 的句柄)。返回一个 PHP 数组会抛出 OxPHP\Shared\TypeException——这种情况尚未支持,已作为独立事项跟踪。若要把结构化的数组状态向上冒泡,要么就地修改 &$value 并在调用后重新读取它,要么把你需要的内容暂存到一个 use (&$captured) 变量中。
| 方法 | 行为 |
|---|---|
withLock($fn) |
阻塞直到获取,然后运行闭包。永久 / 取消。 |
tryWithLock($fn) |
非阻塞。若已被持有则抛出 ContentionException。 |
withLockTimeout($fn, $ms) |
有界等待。要求 $ms > 0。超过截止时间抛出 OperationTimeoutException。 |
id() |
注册表标识符;对日志 / 可观测性很有用。 |
$ms 是一个严格为正的毫秒整数。零、负数、非 int 以及缺省值都会在桥接层抛出 OxPHP\Shared\TypeException——请改用 withLock(永久)或 tryWithLock(非阻塞),而不要试图通过 $ms 来表达那些策略。
为什么 Mutex 抛出异常而 Channel 返回 Result
对一把设计良好的互斥锁而言,争用和超时是罕见事件(锁应当只在很短的临界区内持有;持续的争用是一种坏味道)。而对一个通道来说,它们是常规事件(一个扇出调度器在每个繁忙周期都会遇到 Full/Closed/Timeout)。因此:
Mutex采用异常式——罕见路径就是异常路径。Channel采用 Result 式——常见路径不进入 throw/catch 机制。
如果你发现自己在把每一个 withLock 都包进 try { … } catch (ContentionException) { … },那你用错了原语。对于队列形态的工作负载请选用 Shared\Channel,对于单值原子性请选用 Shared\Counter / Shared\Flag。
同样的结构性原因也解释了为什么 Pool::tryAcquire() 可以返回 null,而 Mutex::tryWithLock() 却抛出异常。Pool 是句柄优先的:tryAcquire(): ?Handle 用 null 承载“饱和”这一含义,而 Handle 本身永远不会是一个用户值,所以不存在歧义。Mutex 是仅闭包的——它刻意从不把锁守卫交回给 PHP(这样一把已持有的锁就无法泄漏到闭包之外),这就没有留下任何可作为可空值返回的对象,而且闭包自身的 mixed 结果本身可能就已经是 null。既然没有空闲的哨兵值,争用便以 ContentionException 的形式浮现。这两个 try* 接口之所以出现分歧,是因为各自能交回什么,而不是风格上的偏好。
示例
原子的多字段更新
当值是单个整数时,一个 Counter 就够用了。当多个字段必须步调一致地更新时,Mutex 更胜一筹:
<?php
$stats = new OxPHP\Shared\Mutex(['hits' => 0, 'bytes' => 0]);
$stats->withLock(function (array &$s) use ($responseBytes) {
$s['hits'] += 1;
$s['bytes'] += $responseBytes;
});另一个观察该值的工作进程会在单个临界区内读取两个字段:
$snapshot = ['hits' => 0, 'bytes' => 0];
$stats->withLock(function (array &$s) use (&$snapshot) {
$snapshot = $s;
});
// $snapshot sees both fields from the same update or neither — never the
// bumped 'hits' without the matching 'bytes'. (We capture through use(&$x)
// because the closure's own return is currently scalar-only — see the
// closure-signature note above.)非阻塞探测 + 降级
<?php
use OxPHP\Shared\{Mutex, ContentionException};
$budget = new Mutex(['tokens' => 100, 'refill_at' => time()]);
try {
$budget->tryWithLock(function (array &$b) {
if ($b['tokens'] <= 0) {
// No tokens — leave state untouched.
return;
}
$b['tokens'] -= 1;
});
} catch (ContentionException) {
// Lock held by another worker — shed the request instead of queuing.
http_response_code(503);
return;
}限时获取
<?php
use OxPHP\Shared\{Mutex, OperationTimeoutException};
$counter = new Mutex(0);
try {
// Return value is scalar — int $next — so the closure return is forwarded.
$next = $counter->withLockTimeout(function (int &$c) {
$c += 1;
return $c;
}, ms: 5000);
} catch (OperationTimeoutException) {
// Someone else held the lock longer than 5s.
}推荐使用具名参数:ms: 5000 读作“5000 毫秒”,无需读者记住参数顺序。
在一处捕获所有并发状况
OperationTimeoutException、ContentionException 和 DeadlockException 都继承自 OxPHP\Async\AsyncException。一个 catch 就能横扫 Shared* 和 Async* 接口上的每一种并发结果:
<?php
use OxPHP\Async\AsyncException;
try {
$state->withLockTimeout($fn, 100);
} catch (AsyncException) {
// timeout, contention, deadlock, or any await-related concurrency error
}从损坏的互斥锁中做灾难性恢复
闭包调用期间发生的 Rust panic(一个服务器缺陷,而非 PHP 代码所为)会使锁陷入粘滞的损坏状态。没有任何等价于 clearPoison() 的东西,因此丢弃该实例:
<?php
use OxPHP\Shared\{Mutex, CorruptedMutexException};
try {
$state->withLock($fn);
} catch (CorruptedMutexException) {
// Old instance is dead. Recreate from the persistent source of truth.
$state = new Mutex($initialState);
}语义与陷阱
让它保持简短。不要调用 sleep,不要在网络 I/O 上阻塞,也不要重新进入其他可能回调进这把互斥锁的 Shared* 类型。
这是相对于此前“任何抛出即中毒”策略的一个刻意改动:部分修改策略现在是“调用方负责恢复不变式”。如果你需要一个非修改性的 try-compute 模式,请在互斥锁之外完成,只在提交最终值时才调用 withLock。
存储值近乎标量。 字符串、int、float、布尔值、null,以及由这些构成的嵌套数组都可以工作。对象、闭包和资源会抛出 TypeException。
闭包返回值涵盖标量、null 和 Shared\* 实例;数组尚不支持。 存储值仍然可以是数组(通过 &$value 修改它),但闭包自身的返回路径只接受 string/int/float/bool/null/字节串,以及任何 OxPHP\Shared\Shareable 句柄。返回一个 PHP 数组会抛出 OxPHP\Shared\TypeException。数组的变通做法:捕获到一个 use (&$x) 变量中,或者通过一个返回标量投影的后续 withLock 来读取状态。
使用另一把互斥锁,或者重构代码。同一线程的重新进入是一个缺陷,不是一个特性。
纤程取消会以 Async\AsyncException 的形式传播。 一个被请求取消打断的 withLock 会抛出该异常,并且锁会被干净地释放。
异常
| 异常 | 父类 | 由谁抛出 |
|---|---|---|
ContentionException |
Async\AsyncException |
在已持有的锁上调用 tryWithLock。 |
OperationTimeoutException |
Async\AsyncException |
withLockTimeout 截止时间到期。 |
DeadlockException |
Async\AsyncException |
同一线程重新进入,或检测到 wait-for 环。 |
CorruptedMutexException |
Shared\SharedException |
先前的一次闭包调用因 Rust panic 而崩溃;互斥锁不可用。 |
TypeException |
Shared\SharedException |
构造函数或 $ms 参数违反了其类型契约。 |
StaleHandleException |
Shared\SharedException |
在一个注册表条目已被逐出的句柄上调用方法。 |
UninitializedException |
Shared\SharedException |
在一个尚未完成 __construct 的包装器上调用 id()。 |
可观测性
参见 Shared Observability。快速参考:
GET /__ox_shared/entry?id=N暴露{ type: "Mutex", corrupted, waiters, last_acquire_ms, held_by_thread }。- 每个实例的 Prometheus 指标:
oxphp_shared_mutex_waiters{mutex_id="…"}—— 当前的等待者数量。oxphp_shared_mutex_acquires_total{mutex_id="…"}—— 生命周期内的获取次数。oxphp_shared_mutex_contended_total{mutex_id="…"}—— 不得不等待的获取次数。oxphp_shared_mutex_corrupted{mutex_id="…"}—— 0 / 1(由_poisoned重命名而来)。
何时不该使用
- 单个原子值。 如果被守护的值是一个 int 或一个 bool,请使用
Shared\Counter或Shared\Flag——两者都是无锁的,且开销更低。 - 长时间运行的工作。 不要在 I/O、
sleep或纤程 await 期间持有互斥锁。请改用Shared\Channel的生产者/消费者模式。 - 高争用热点路径。 如果每个请求都必须获取同一把互斥锁,你就把自己的吞吐量串行化了。请对状态进行分区(例如
Shared\Map<tenant_id, Mutex>),或者在每个工作进程的本地变量中预聚合并定期刷写。 - 跨主机互斥。 仅限进程内。多主机协调请使用分布式锁(Redis
SET NX、etcd)。
从此前的 API 迁移
| 曾经 | 现在 |
|---|---|
$m->with($fn)(永久) |
$m->withLock($fn) |
$m->with($fn, $secs) |
$m->withLockTimeout($fn, $ms),其中 $ms 以毫秒为单位 |
$m->tryWith($fn) → 争用时返回 null |
$m->tryWithLock($fn) → 抛出 ContentionException |
$m->isPoisoned() / $m->clearPoison() |
已移除;PHP 抛出的异常不再破坏互斥锁 |
PoisonedException(Rust panic 路径) |
CorruptedMutexException(无公开的清除 API) |
Shared\TimeoutException |
Shared\OperationTimeoutException(现在继承自 Async\AsyncException) |
DeadlockException extends Shared\TimeoutException |
DeadlockException extends Async\AsyncException |
闭包签名也从 function (mixed $value): mixed(返回即提交)改成了 function (mixed &$value): mixed(按引用修改,正常返回的是闭包自身的值,而非新状态)。如果闭包什么都不返回,存储值会保留按引用修改在它上面留下的任何内容。有一个既有限制会延续下来:闭包的返回值必须是标量(string / int / float / bool / null / 字节串)或一个 Shared\* 句柄——返回一个 PHP 数组会抛出 OxPHP\Shared\TypeException。存储值仍然可以是数组;通过 &$value 修改它,并用 use (&$x) 把结构化数据向上冒泡。
相关
- Shared State —— 概述与心智模型。
- Shared\Counter —— 当被守护的状态是一个整数时。
- Shared\Flag —— 当被守护的状态是一个 bool 时。
- Shared\Channel —— 当你需要的是等待 + 交接,而不是互斥(并且想要 Result 式返回而非异常式返回)时。
- Shared\Map —— 为每个键分区一把 Mutex,以避免全局争用。