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 参考

php
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(): ?Handlenull 承载“饱和”这一含义,而 Handle 本身永远不会是一个用户值,所以不存在歧义。Mutex仅闭包的——它刻意从不把锁守卫交回给 PHP(这样一把已持有的锁就无法泄漏到闭包之外),这就没有留下任何可作为可空值返回的对象,而且闭包自身的 mixed 结果本身可能就已经是 null。既然没有空闲的哨兵值,争用便以 ContentionException 的形式浮现。这两个 try* 接口之所以出现分歧,是因为各自能交回什么,而不是风格上的偏好。

示例

原子的多字段更新

当值是单个整数时,一个 Counter 就够用了。当多个字段必须步调一致地更新时,Mutex 更胜一筹:

php
<?php $stats = new OxPHP\Shared\Mutex(['hits' => 0, 'bytes' => 0]); $stats->withLock(function (array &$s) use ($responseBytes) { $s['hits'] += 1; $s['bytes'] += $responseBytes; });

另一个观察该值的工作进程会在单个临界区内读取两个字段:

php
$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
<?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
<?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 毫秒”,无需读者记住参数顺序。

在一处捕获所有并发状况

OperationTimeoutExceptionContentionExceptionDeadlockException 都继承自 OxPHP\Async\AsyncException。一个 catch 就能横扫 Shared* 和 Async* 接口上的每一种并发结果:

php
<?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
<?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* 类型。

PHP 抛出的异常不再破坏锁

这是相对于此前“任何抛出即中毒”策略的一个刻意改动:部分修改策略现在是“调用方负责恢复不变式”。如果你需要一个非修改性的 try-compute 模式,请在互斥锁之外完成,只在提交最终值时才调用 withLock

存储值近乎标量。 字符串、int、float、布尔值、null,以及由这些构成的嵌套数组都可以工作。对象、闭包和资源会抛出 TypeException

闭包返回值涵盖标量、nullShared\* 实例;数组尚不支持。 存储值仍然可以是数组(通过 &$value 修改它),但闭包自身的返回路径只接受 string/int/float/bool/null/字节串,以及任何 OxPHP\Shared\Shareable 句柄。返回一个 PHP 数组会抛出 OxPHP\Shared\TypeException。数组的变通做法:捕获到一个 use (&$x) 变量中,或者通过一个返回标量投影的后续 withLock 来读取状态。

在同一线程上重新进入会抛出 DeadlockException

使用另一把互斥锁,或者重构代码。同一线程的重新进入是一个缺陷,不是一个特性。

纤程取消会以 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\CounterShared\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,以避免全局争用。