Shared\Mutex
OxPHP\Shared\Mutex is a process-wide mutual-exclusion lock that wraps a stored value. You never touch the lock directly. Instead, you hand a closure to one of three method variants, and the runtime holds the lock for the closure's duration and releases it even if the closure throws.
Overview
- Guards a value, not just a section. The wrapped value is passed into your closure by reference, so direct mutation inside the closure commits back when the closure returns normally.
- Three explicit wait policies instead of one overloaded
?float $timeout:withLock($fn)— block forever (or until the request fiber is cancelled).tryWithLock($fn)— non-blocking; throwsContentionExceptionif the lock is held.withLockTimeout($fn, int $ms)— bounded wait; throwsOperationTimeoutExceptionon deadline expiry.
- PHP exceptions propagate freely. If your closure throws an ordinary PHP exception, the lock releases and the exception bubbles up. The mutex is not corrupted — partial mutation is acceptable; the caller is responsible for restoring invariants.
- Rust panics corrupt the mutex. If a Rust panic crosses the FFI boundary (a server bug), the mutex enters a sticky corrupted state and every subsequent acquisition throws
CorruptedMutexException. There is no recovery API — discard the instance and create a new one. - Deadlock-avoidant. Re-entering the same mutex on the same thread (including via nested async calls captured on this thread) raises
DeadlockExceptioninstead of hanging.
API reference
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;
}The closure signature is function (mixed &$value): mixed — $value is passed by reference, so you mutate it in place. The closure's normal return value is forwarded to the caller of withLock / tryWithLock / withLockTimeout. The return path supports scalars, null, and Shared\* instances (string, int, float, bool, byte-string, null, and any handle implementing OxPHP\Shared\Shareable). Returning a PHP array raises OxPHP\Shared\TypeException — that case is not yet supported and is tracked separately. To bubble structured array state up, either mutate &$value in place and re-read it after the call, or stage what you need in a use (&$captured) variable.
| Method | Behaviour |
|---|---|
withLock($fn) |
Block until acquired, then run the closure. Forever / cancel. |
tryWithLock($fn) |
Non-blocking. Throws ContentionException if held. |
withLockTimeout($fn, $ms) |
Bounded wait. $ms > 0 required. Throws OperationTimeoutException on deadline. |
id() |
Registry identifier; useful for logging / observability. |
$ms is a strict positive integer of milliseconds. Zero, negative, non-int, and absent values raise OxPHP\Shared\TypeException at the bridge — call withLock (forever) or tryWithLock (non-blocking) instead of trying to express those policies through $ms.
Why Mutex throws but Channel returns Results
Contention and timeout are rare events for a well-designed mutex (locks should be held for short critical sections; sustained contention is a smell). They are routine events for a channel (a fan-out dispatcher sees Full/Closed/Timeout on every busy cycle). So:
Mutexuses exception-style — the rare path is the exceptional one.Channeluses Result-style — the common path stays out of the throw/catch machinery.
If you find yourself wrapping every withLock in try { … } catch (ContentionException) { … }, you are using the wrong primitive. Reach for Shared\Channel for queue-shaped workloads or Shared\Counter / Shared\Flag for single-value atomicity.
The same structural reason explains why Pool::tryAcquire() can return null where Mutex::tryWithLock() throws. Pool is handle-first: tryAcquire(): ?Handle carries "saturated" as null, and a Handle is never itself a user value, so there is no ambiguity. Mutex is closure-only — it deliberately never hands a lock guard back to PHP (so a held lock can't leak past the closure), which leaves no object to return as nullable, and the closure's own mixed result may already be null. With no free sentinel, contention surfaces as ContentionException. The two try* surfaces diverge because of what each type can hand back, not a style preference.
Examples
Atomic multi-field update
A Counter is enough when the value is a single integer. A Mutex wins when several fields must update in lockstep:
<?php
$stats = new OxPHP\Shared\Mutex(['hits' => 0, 'bytes' => 0]);
$stats->withLock(function (array &$s) use ($responseBytes) {
$s['hits'] += 1;
$s['bytes'] += $responseBytes;
});Another worker observing the value reads both fields in a single critical section:
$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.)Non-blocking probe + degrade
<?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;
}Timed acquire
<?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.
}Named arguments are encouraged: ms: 5000 reads as "5000 milliseconds" without needing the reader to remember the parameter order.
Catch all concurrency conditions in one place
OperationTimeoutException, ContentionException, and DeadlockException all extend OxPHP\Async\AsyncException. One catch sweeps every concurrency outcome across the Shared* and Async* surfaces:
<?php
use OxPHP\Async\AsyncException;
try {
$state->withLockTimeout($fn, 100);
} catch (AsyncException) {
// timeout, contention, deadlock, or any await-related concurrency error
}Catastrophic recovery from a corrupted mutex
A Rust panic during closure invocation (a server bug, not anything the PHP code did) leaves the lock in a sticky corrupted state. There is no clearPoison() equivalent, so discard the instance:
<?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);
}Semantics & gotchas
Keep it short. Do not call sleep, do not block on network I/O, and do not re-enter other Shared* types that could call back into this mutex.
This is a deliberate change from the previous Poisoned-on-any-throw policy: the partial-mutation policy is now "caller is responsible for restoring invariants." If you need a non-mutating try-compute pattern, do it outside the mutex and call withLock only to commit the final value.
Stored value is scalar-ish. Strings, ints, floats, booleans, null, and nested arrays of those work. Objects, closures, and resources raise TypeException.
Closure return covers scalars, null, and Shared\* instances; arrays are not yet supported. The stored value can still be an array (mutate it via &$value), but the closure's own return path accepts string/int/float/bool/null/byte-string and any OxPHP\Shared\Shareable handle. Returning a PHP array raises OxPHP\Shared\TypeException. Workaround for arrays: capture into a use (&$x) variable, or read the state through a follow-up withLock that returns a scalar projection.
Use a different mutex or restructure the code. Same-thread re-entry is a bug, not a feature.
Fiber cancellation propagates as Async\AsyncException. A withLock interrupted by request cancellation raises that exception, and the lock is released cleanly.
Exceptions
| Exception | Parent | Raised by |
|---|---|---|
ContentionException |
Async\AsyncException |
tryWithLock on a held lock. |
OperationTimeoutException |
Async\AsyncException |
withLockTimeout deadline expired. |
DeadlockException |
Async\AsyncException |
Same-thread re-entry or detected wait-for cycle. |
CorruptedMutexException |
Shared\SharedException |
A prior closure invocation crashed via Rust panic; mutex is unusable. |
TypeException |
Shared\SharedException |
Constructor or $ms argument violated its type contract. |
StaleHandleException |
Shared\SharedException |
Method call on a handle whose registry entry was evicted. |
UninitializedException |
Shared\SharedException |
id() on a wrapper that has not finished __construct. |
Observability
See Shared Observability. Quick references:
GET /__ox_shared/entry?id=Nexposes{ type: "Mutex", corrupted, waiters, last_acquire_ms, held_by_thread }.- Prometheus metrics per instance:
oxphp_shared_mutex_waiters{mutex_id="…"}— current waiter count.oxphp_shared_mutex_acquires_total{mutex_id="…"}— lifetime acquires.oxphp_shared_mutex_contended_total{mutex_id="…"}— acquires that had to wait.oxphp_shared_mutex_corrupted{mutex_id="…"}— 0 / 1 (renamed from_poisoned).
When not to use
- Single atomic value. If the guarded value is one int or one bool, use
Shared\CounterorShared\Flag— both are lock-free and cheaper. - Long-running work. Do not hold a mutex across I/O,
sleep, or fiber awaits. Use aShared\Channelproducer/consumer pattern instead. - High-contention hot path. If every request must take the same mutex, you have serialised your throughput. Partition the state (e.g.
Shared\Map<tenant_id, Mutex>) or pre-aggregate in per-worker locals and flush periodically. - Cross-host mutual exclusion. In-process only. Use a distributed lock (Redis
SET NX, etcd) for multi-host coordination.
Migrating from the previous API
| Was | Is |
|---|---|
$m->with($fn) (forever) |
$m->withLock($fn) |
$m->with($fn, $secs) |
$m->withLockTimeout($fn, $ms) with $ms in milliseconds |
$m->tryWith($fn) → null on contention |
$m->tryWithLock($fn) → throws ContentionException |
$m->isPoisoned() / $m->clearPoison() |
removed; PHP throws no longer corrupt the mutex |
PoisonedException (Rust panic path) |
CorruptedMutexException (no public clear API) |
Shared\TimeoutException |
Shared\OperationTimeoutException (now extends Async\AsyncException) |
DeadlockException extends Shared\TimeoutException |
DeadlockException extends Async\AsyncException |
The closure signature also changed from function (mixed $value): mixed (return-to-commit) to function (mixed &$value): mixed (by-ref mutation, normal return is the closure's value, not the new state). If the closure returns nothing, the stored value keeps whatever the by-ref mutation left in it. One pre-existing limitation carries forward: the closure's return value must be a scalar (string / int / float / bool / null / byte-string) or a Shared\* handle — returning a PHP array throws OxPHP\Shared\TypeException. The stored value can still be an array; mutate it through &$value and use use (&$x) to bubble structured data up.
Related
- Shared State — overview and mental model.
- Shared\Counter — when the guarded state is one integer.
- Shared\Flag — when the guarded state is one bool.
- Shared\Channel — when you need waiting + handoff rather than mutual exclusion (and want Result-style returns instead of exception-style).
- Shared\Map — partition a Mutex per key to avoid global contention.