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, byte-string, null і будь-який дескриптор, що реалізує OxPHP\Shared\Shareable). Повернення PHP-масиву кидає OxPHP\Shared\TypeException — цей випадок поки не підтримується й відстежується окремо. Щоб передати вгору структурований стан у вигляді масиву, або змініть &$value на місці й перечитайте його після виклику, або складіть потрібне у змінну use (&$captured).

Метод Поведінка
withLock($fn) Блокується до захоплення, потім виконує замикання. Чекає стільки, скільки живе запит.
tryWithLock($fn) Неблокувальний. Кидає ContentionException, якщо блокування утримується.
withLockTimeout($fn, $ms) Обмежене очікування. Потрібно $ms > 0. Кидає OperationTimeoutException, коли спливає дедлайн.
id() Ідентифікатор у реєстрі; корисний для журналювання й спостережуваності.

$ms — суворо додатне ціле число мілісекунд. Нуль, від’ємні, нецілі й відсутні значення кидають OxPHP\Shared\TypeException на рівні мосту — викликайте withLock (без обмеження) або tryWithLock (неблокувальний), замість того щоб виражати ці політики через $ms.

Чому Mutex кидає винятки, а Channel повертає Result

Конкуренція за блокування й тайм-аут — рідкісні події для добре спроєктованого м’ютекса (блокування слід утримувати протягом коротких критичних секцій; стала конкуренція — тривожна ознака). Для каналу ж це буденні події (диспетчер розсилання бачить 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. Вільного значення-сигналу немає, тому конкуренція проявляється як 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, не блокуйтеся на мережевому введенні-виведенні й не заходьте повторно в інші типи Shared*, які можуть звернутися назад до цього м’ютекса.

PHP-винятки більше не пошкоджують блокування

Це навмисна зміна порівняно з попередньою політикою «Poisoned за будь-якого винятку»: тепер політика часткових змін звучить так: «за відновлення інваріантів відповідає код, що викликає». Якщо вам потрібен шаблон «спробувати обчислити» без змін, виконайте обчислення поза м’ютексом і викликайте withLock лише для фіксації остаточного значення.

Збережене значення — майже скалярне. Підходять рядки, цілі числа, числа з рухомою комою, булеві значення, null і вкладені масиви з них. Об’єкти, замикання й ресурси кидають TypeException.

Повернення із замикання охоплює скаляри, null та екземпляри Shared\*; масиви поки не підтримуються. Збережене значення все ще може бути масивом (змінюйте його через &$value), але власний шлях повернення замикання приймає string/int/float/bool/null/byte-string і будь-який дескриптор 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).

Коли не варто використовувати

  • Одне атомарне значення. Якщо захищене значення — одне ціле число або один bool, використовуйте Shared\Counter або Shared\Flag: обидва працюють без блокувань і дешевші.
  • Тривала робота. Не утримуйте м’ютекс під час введення-виведення, 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 / byte-string) або дескриптором Shared\* — повернення PHP-масиву кидає OxPHP\Shared\TypeException. Збережене значення все ще може бути масивом; змінюйте його через &$value і використовуйте use (&$x), щоб передати структуровані дані вгору.

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

Ці промпти розраховані на агента з підключеним MCP-сервером документації OxPHP — потрібні сторінки він прочитає сам.

prompt
Використай OxPHP Shared Mutex у моєму коді замість стану з кількох полів, який я оновлюю без блокування або через `flock()`, коли два чи більше полів мають змінюватися разом. Прочитай сторінку `shared-state/shared-mutex` через MCP-сервер документації OxPHP. Знайди це оновлення в цьому репозиторії й покажи мені замикання `withLock`, яке його замінює, щоб усі поля змінювалися разом.

Пов’язані сторінки

  • Shared State — огляд і ментальна модель.
  • Shared\Counter — коли захищений стан — одне ціле число.
  • Shared\Flag — коли захищений стан — один bool.
  • Shared\Channel — коли потрібні очікування й передавання, а не взаємне виключення (і ви хочете повернення у стилі Result замість винятків).
  • Shared\Map — розділіть Mutex за ключами, щоб уникнути глобальної конкуренції.