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

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

Ожидание withLock заканчивается вместе с запросом. withLock, всё ещё ожидающий блокировку, перестаёт ждать, когда истекает max_execution_time, когда сервер завершает работу после дедлайна дренажа, а запрос всё ещё выполняется, или когда асинхронная задача, в которой идёт вызов, отменяется во время ожидания. Дальше запрос завершается так же, как отмена завершает его в любом другом месте: таймаут даёт обычную фатальную ошибку 504, которую ваш код перехватить не может, а отменённая асинхронная задача получает Async\AsyncException. При таймауте в логе (и в ответе, если включён display_errors) непосредственно перед фатальной ошибкой таймаута также появляется предупреждение Uncaught OxPHP\Async\AsyncException: Mutex::withLock: wait abandoned, the request is being ended: ожидание выходит, бросая это исключение, а PHP сообщает об исключении, которое ещё не обработано в момент фатальной ошибки. Отключение клиента ожидание не прерывает. При max_execution_time = 0 и без завершения сервера у ожидания нет конца, поэтому там, где ожиданию нужна собственная граница, используйте withLockTimeout. В асинхронной задаче max_execution_time не прерывает ожидание ни при каком значении, потому что у потока пула, выполняющего задачу, нет дедлайна выполнения. Там ожидание завершает отмена задачи (аргумент таймаута в oxphp_async_await()) или withLockTimeout.

Фатальная ошибка внутри замыкания освобождает блокировку и отбрасывает его записи

Фатальная ошибка — в том числе истечение max_execution_time, пока замыкание выполняется или спит, — по-прежнему завершает запрос: её нельзя перехватить, и ничего после вызова withLock не выполняется. На выходе блокировка освобождается, а мьютекс сохраняет значение, которое было у него до начала работы замыкания. В этом фатальная ошибка и отличается от исключения: замыкание, бросившее исключение, сохраняет свои частичные изменения (см. выше), а замыкание, прерванное фатальной ошибкой, так и не возвращает значение, поэтому ни одна из его записей не сохраняется. То же относится к tryWithLock и withLockTimeout.

Исключения

Исключение Родитель Бросается
ContentionException Async\AsyncException tryWithLock на удерживаемой блокировке.
OperationTimeoutException Async\AsyncException Истёк дедлайн withLockTimeout.
DeadlockException Async\AsyncException Повторный вход на том же потоке или цикл ожидания, разорванный при SHARED_LOCK_DIAGNOSTICS=strict (см. Детектор взаимоблокировок).
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), чтобы вынести структурированные данные наверх.

Промпты для вашего агента

Эти промпты рассчитаны на агента с подключённым MCP-сервером документации OxPHP — нужные страницы он прочитает сам.

prompt
Используй в моём коде Shared Mutex из OxPHP вместо состояния из нескольких полей, которое я обновляю без блокировки или через `flock()`, когда два и более поля обязаны меняться вместе. Прочитай страницу `shared-state/shared-mutex` через MCP-сервер документации OxPHP. Найди это обновление в репозитории и покажи замыкание `withLock`, которое его заменяет, чтобы все поля записывались разом.

Связанное

  • Разделяемое состояние — обзор и ментальная модель.
  • Shared\Counter — когда защищаемое состояние — одно целое число.
  • Shared\Flag — когда защищаемое состояние — один bool.
  • Shared\Channel — когда нужны ожидание + передача, а не взаимное исключение (и вы хотите возвраты в стиле Result, а не в стиле исключений).
  • Shared\Map — разбить Mutex по ключу, чтобы избежать глобальной конкуренции.
Нашли ошибку? Сообщите →