Мьютексные блокировки в php фреймворках
Решил я разобраться как устроены мьютексы в различных фреймворках. А началось всё с того, что произошёл у меня спор с коллегой по поводу того, как отработает код в Yii2:
$mutex->acquire('my-lock', 10);
Поскольку в нашем проекте мьютексы хранятся в Redis, я был уверен, что число 10 в этом контексте обозначает TTL (Time To Live) блокировки, то есть время, через которое блокировка будет автоматически снята. Это предположение основывалось на том, что в команде Redis для установки блокировки используется следующий синтаксис:
SET lock_name unique_value NX PX 5000
Здесь создаётся блокировка с именем lock_name, где "владельцем" является unique_value (снять блокировку можно только, указав это значение) на 5000 миллисекунд, если такого ключа у "владельца" нет.
Кроме того, мой опыт работы с Symfony подсказывал, что при создании мьютекса можно передать только TTL. Однако, как оказалось, в случае с Yii2 я ошибался. Изучив код, я понял, что параметр 10 в методе acquire обозначает не TTL, а таймаут — время, в течение которого процесс будет ожидать освобождения мьютекса.
Как устроены мьютексы в PHP-фреймворках?
Рассмотрим интерфейсы и их реализацию в различных фреймворках, поскольку на глубоком уровне они работают схожим образом, особенно при использовании Redis.
Yii2
if (!$mutex->acquire($mutexName)) {
return;
}
try {
// критическая секция
} finally {
$mutex->release();
}
При таком использовании метод acquire сразу вернёт false, если блокировка не может быть установлена, и ожидание не произойдёт. Если вызвать $mutex->acquire($mutexName, 10), процесс будет ожидать до 10 секунд, пока мьютекс не освободится. Однако TTL также можно задать.
Yii3
В Yii3 добавлена обёртка \Yiisoft\Mutex\Synchronizer:
$synchronizer = new Synchronizer($mutexFactory);
$result = $synchronizer->execute('my-lock', function () {
// критическая секция
}, 10); // таймаут 10 секунд
Здесь освобождение мьютекса происходит внутри Synchronizer, что удобнее и безопаснее. Это избавляет от необходимости писать собственные обёртки. Для тех, кто предпочитает полный контроль, мьютексы можно использовать так же, как в Yii2.
Laravel
В Laravel мьютексы являются частью пакета кеширования:
Cache::lock('my-critical-section', 10)->block(5, function () {
// критическая секция
});
Здесь создаётся мьютекс с TTL 10 секунд и таймаутом 5 секунд. Освобождение происходит автоматически. Можно также выполнить всё вручную:
$lock = Cache::lock('my-task', 10);
if ($lock->get()) {
try {
// критическая секция
} finally {
$lock->release();
}
}
Мьютексные блокировки часто применяются для предотвращения параллельного запуска фоновых задач. В Laravel существует middleware WithoutOverlapping в пакете очередей, который использует тот же мьютекс, предоставляя удобную обёртку для типовых задач.
Symfony
Symfony предоставляет, пожалуй, самый гибкий и полный пакет для работы с мьютексами:
$lock = $factory->createLock('pdf-creation', ttl: 30);
if (!$lock->acquire()) {
return;
}
try {
// выполнение задачи менее 30 секунд
} finally {
$lock->release();
}
Здесь блокировка создаётся в неблокирующем режиме. Можно создать и в блокирующем, используя acquire(true). Также можно обновлять TTL мьютекса через $lock->refresh(), если становится понятно, что первоначального TTL недостаточно.
Особое внимание в этом пакете привлекают блокировки с разделением доступа. Поддержка этой функции зависит от используемого адаптера; для Redis она доступна. В то время как acquire захватывает исключительную блокировку, после которой никакой другой процесс не сможет ни читать, ни писать в защищённый ресурс, acquireRead (разделяемая блокировка) позволяет нескольким процессам захватывать блокировку в режиме чтения, пока кто-то не выполнит исключительную блокировку.
Подобные библиотеки делают использование мьютексов простым и удобным, скрывая тонкости реализации. Например, последовательное выполнение команд GET (проверка наличия ключа) и DEL (удаление ключа) для снятия мьютекса не гарантирует атомарность операции, поскольку между выполнением этих команд значение ключа может измениться другим процессом. Поэтому требуется выполнение Lua-скрипта:
if redis.call("GET", KEYS[1]) == ARGV[1] then
return redis.call("DEL", KEYS[1])
else
return 0
end
Этот скрипт обеспечивает атомарность операции удаления мьютекса, проверяя значение ключа перед удалением.
Подписывайся на канал 🤘