Shared\Mutex
OxPHP\Shared\Mutex — это блокировка взаимного исключения в пределах всего процесса, оборачивающая хранимое значение. Напрямую вы блокировку никогда не трогаете. Вместо этого вы передаёте замыкание в один из трёх вариантов метода, а рантайм удерживает блокировку на время работы замыкания и освобождает её, даже если замыкание бросит исключение.
Обзор
- Защищает значение, а не просто участок кода. Обёрнутое значение передаётся в ваше замыкание по ссылке, поэтому его прямое изменение внутри замыкания фиксируется обратно, когда замыкание завершается нормально.
- Три явные политики ожидания вместо одного перегруженного
?float $timeout:withLock($fn)— блокировать навсегда (или пока файбер запроса не будет отменён).tryWithLock($fn)— без блокировки; бросаетContentionException, если блокировка уже удерживается.withLockTimeout($fn, int $ms)— ограниченное ожидание; бросаетOperationTimeoutExceptionпо истечении дедлайна.
- PHP-исключения распространяются свободно. Если ваше замыкание бросает обычное PHP-исключение, блокировка освобождается, а исключение всплывает наверх. Мьютекс при этом не портится — частичное изменение допустимо; за восстановление инвариантов отвечает вызывающая сторона.
- Паники Rust портят мьютекс. Если паника Rust пересекает границу 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\* (string, 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
Конкуренция за блокировку и таймаут — редкие события для хорошо спроектированного мьютекса (блокировки следует удерживать на коротких критических участках; устойчивая конкуренция — это дурной запах). Для канала же они — рутинные события (диспетчер fan-out видит 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 guard-объект блокировки (чтобы удерживаемая блокировка не могла утечь за пределы замыкания), из-за чего не остаётся объекта, который можно было бы вернуть как nullable, а собственный mixed-результат замыкания и сам может быть null. Свободного sentinel-значения нет, поэтому конкуренция проявляется как 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 во время вызова замыкания (баг сервера, а не что-либо, что сделал 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*, которые могли бы вызвать обратный вход в этот мьютекс.
Это намеренное изменение по сравнению с прежней политикой Poisoned-on-any-throw: теперь политика частичного изменения — «за восстановление инвариантов отвечает вызывающая сторона». Если вам нужен не изменяющий состояние паттерн 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 |
Повторный вход на том же потоке или обнаруженный цикл ожидания. |
CorruptedMutexException |
Shared\SharedException |
Предыдущий вызов замыкания упал из-за паники Rust; мьютекс непригоден. |
TypeException |
Shared\SharedException |
Конструктор или аргумент $ms нарушил свой контракт типа. |
StaleHandleException |
Shared\SharedException |
Вызов метода на хендле, чья запись в реестре была вытеснена. |
UninitializedException |
Shared\SharedException |
id() на обёртке, не завершившей __construct. |
Наблюдаемость
См. Наблюдаемость Shared. Краткие справки:
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или ожидания файберов. Используйте паттерн производитель/потребитель на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) |
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\Counter — когда защищаемое состояние — одно целое число.
- Shared\Flag — когда защищаемое состояние — один bool.
- Shared\Channel — когда нужны ожидание + передача, а не взаимное исключение (и вы хотите возвраты в стиле Result, а не в стиле исключений).
- Shared\Map — разбить Mutex по ключу, чтобы избежать глобальной конкуренции.