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

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\* (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
<?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 миллисекунд», и читателю не нужно помнить порядок параметров.

Обработка всех условий конкурентности в одном месте

OperationTimeoutException, ContentionException и DeadlockException — все они наследуют 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 во время вызова замыкания (баг сервера, а не что-либо, что сделал 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-исключения больше не портят блокировку

Это намеренное изменение по сравнению с прежней политикой 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, возвращающий скалярную проекцию.

Повторный вход на том же потоке бросает DeadlockException

Используйте другой мьютекс или перестройте код. Повторный вход на том же потоке — это баг, а не фича.

Отмена файбера распространяется как 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 по ключу, чтобы избежать глобальной конкуренции.