April 9

Мьютексные блокировки в 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

Этот скрипт обеспечивает атомарность операции удаления мьютекса, проверяя значение ключа перед удалением.

Подписывайся на канал 🤘