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
- The mutex guards a value as well as a section of code. 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 take the place of one overloaded
?float $timeout:withLock($fn)blocks until the lock is acquired, with no time limit of its own. The wait ends early only when the request or async task it runs in is ended.tryWithLock($fn)is non-blocking and throwsContentionExceptionif the lock is held.withLockTimeout($fn, int $ms)waits for a bounded time and throwsOperationTimeoutExceptionwhen the deadline expires.
- 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, and 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, so discard the instance and create a new one. - Re-entry does not deadlock. 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. Waits as long as the request lives. |
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:
Mutexis exception-style, because the rare path is the exceptional one.Channelis Result-style, which keeps the common path 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. Style preference is not the reason.
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.
The stored value must be scalar-ish. Strings, ints, floats, booleans, null, and nested arrays of those work. Objects, closures, and resources raise TypeException.
The closure's return covers scalars, null, and Shared\* instances, but not arrays yet. 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.
A withLock wait ends when the request ends. A withLock still waiting for the lock stops waiting when max_execution_time runs out, when the server shuts down past its drain deadline with the request still running, or when the async task the call runs in is cancelled while it waits. The request then ends the same way that cancellation ends it anywhere else: a timeout produces the usual 504 fatal error, which your code cannot catch, while a cancelled async task sees Async\AsyncException. On a timeout the log (and the response, where display_errors is on) also carries a warning Uncaught OxPHP\Async\AsyncException: Mutex::withLock: wait abandoned, the request is being ended just ahead of the timeout fatal: the wait leaves by throwing that exception, and PHP reports an exception still in flight when a fatal error arrives. A client disconnect does not stop the wait. With max_execution_time = 0 and no shutdown, the wait has no end, so use withLockTimeout where the wait needs a bound of its own. In an async task max_execution_time does not stop the wait at any value, because the pool thread running the task holds no execution deadline. What ends the wait there is the task being cancelled (the timeout argument of oxphp_async_await()) or withLockTimeout.
A fatal error, max_execution_time running out while the closure runs or sleeps included, still ends the request: it cannot be caught, and nothing after the withLock call runs. On its way out the lock is released, and the mutex keeps the value it had before the closure started. This is where a fatal differs from a throw: a closure that throws keeps its partial writes (see above), while one ended by a fatal never hands the value back, so none of its writes are kept. The same applies to tryWithLock and withLockTimeout.
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 a wait-for cycle broken under SHARED_LOCK_DIAGNOSTICS=strict (see Deadlock detector). |
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
- If the guarded value is a single int or bool, use
Shared\CounterorShared\Flaginstead. Both are lock-free and cheaper. - Do not hold a mutex across long-running work such as I/O,
sleep, or fiber awaits. Use aShared\Channelproducer/consumer pattern instead. - On a high-contention hot path, where 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. - The mutex is in-process only, so it cannot provide cross-host mutual exclusion. 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, and 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.
Prompts for your agent
These prompts assume an agent with the OxPHP documentation MCP server connected. The agent reads the pages it needs itself.
Use an OxPHP Shared Mutex in my code instead of the multi-field state I update with no lock, or with `flock()`, when two or more fields must change together. Read the `shared-state/shared-mutex` page through the OxPHP documentation MCP server.
Find that update in this repository and show me the `withLock` closure that replaces it, so every field lands together.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.