Реализация блокировок для многопоточных приложений на PHP
Блокировки в PHP: управление конкурентным доступом
В многопроцессной среде (например, при параллельном выполнении cron-задач или обработке веб-запросов через несколько воркеров) возникает необходимость синхронизировать доступ к общим ресурсам: файлам, записям в базе данных, внешним API. Блокировки (locks) предотвращают состояние гонки и гарантируют атомарность операций. Рассмотрим различные подходы к реализации блокировок в PHP, от встроенных инструментов до распределённых решений.
Как организовать распределённую блокировку с автоматическим снятием?
Наиболее эффективным и гибким решением является Redis-блокировка (Redlock или упрощённый вариант через SET NX EX). Redis работает в памяти, обеспечивает низкую задержку и поддерживает тайм-ауты, что предотвращает вечные блокировки.
// Подключение к Redis (предполагается phpredis)
$redis = new \Redis();
$redis->connect('127.0.0.1', 6379);
$lockKey = 'my_resource_lock';
$lockValue = uniqid('', true); // Уникальный идентификатор для защиты от случайного снятия
$ttl = 10; // время блокировки в секундах
// Попытка установить блокировку (SET NX EX)
$locked = $redis->set($lockKey, $lockValue, ['NX', 'EX' => $ttl]);
if ($locked) {
try {
// Критическая секция
echo "Блокировка получена, выполняем задачу.\n";
sleep(5); // имитация работы
} finally {
// Освобождаем блокировку (только если значение совпадает)
$script = '
if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("del", KEYS[1])
else
return 0
end
';
$redis->eval($script, [$lockKey, $lockValue], 1);
}
} else {
echo "Не удалось получить блокировку.\n";
}
Php threads (многопоточность в php)
Проблемы и их решения:
- Снятие чужой блокировки: использование Lua-скрипта с проверкой
GETгарантирует, что только владелец удаляет свой ключ. - Зависший процесс: TTL (EX) автоматически снимает блокировку, если процесс упал.
- Гонка при одновременной установке: команда
SET NXатомарна.
Подходит для распределённых систем и микросервисов, где требуется быстрая блокировка на общем хранилище.
Как заблокировать файл от одновременного доступа процессов?
Встроенная функция flock() использует системный механизм блокировок файлов. Подходит для синхронизации процессов на одной машине.
$fp = fopen('/tmp/my_lock.lock', 'w');
if (flock($fp, LOCK_EX)) { // Эксклюзивная блокировка
echo "Файл заблокирован.\n";
//... работа с разделяемым файлом
sleep(3);
flock($fp, LOCK_UN); // освобождение
} else {
echo "Не удалось получить блокировку.\n";
}
fclose($fp);
Php lock (блокировки в php)
Типичные ошибки:
- Забыть закрыть файл или снять блокировку – приведёт к зависанию других процессов. Рекомендуется использовать
try...finally. - Файл блокировки должен быть доступен записи всем процессам.
flockне работает с NFS без специальных опций.
Используется для кэширования, очередей на файлах, одиночного выполнения заданий.
Как использовать встроенную блокировку MySQL без внешних инструментов?
MySQL предоставляет функции GET_LOCK() и RELEASE_LOCK(), работающие на уровне сессии. Блокировка действует, пока соединение живо.
$mysqli = new mysqli('localhost', 'user', 'pass', 'db');
$lockName = 'my_app_lock';
$timeout = 5; // секунд ожидания
$result = $mysqli->query("SELECT GET_LOCK('$lockName', $timeout) AS res");
$row = $result->fetch_assoc();
if ($row['res'] == 1) {
echo "Блокировка получена.\n";
// ... критическая секция
$mysqli->query("SELECT RELEASE_LOCK('$lockName')");
} else {
echo "Блокировка не получена или истёк тайм-аут.\n";
}
Проблемы:
- Блокировка привязана к сессии MySQL. При обрыве соединения она может сняться автоматически или остаться.
- Не подходит для высоконагруженных систем, так как блокировки занимают ресурсы сервера.
- Имена блокировок должны быть уникальными; используйте префиксы.
Удобно, если приложение уже использует MySQL и не требуется дополнительная инфраструктура.
Как синхронизировать процессы через разделяемую память?
Расширение sysvsem предоставляет семафоры System V. Это низкоуровневый механизм, работающий на уровне ядра.
$semKey = ftok(__FILE__, 'b'); // уникальный ключ
$sem = sem_get($semKey, 1); // один семафор
if (sem_acquire($sem)) {
echo "Семафор захвачен.\n";
// ... критическая секция
sem_release($sem);
} else {
echo "Не удалось захватить семафор.\n";
}
Особенности:
- Семафоры существуют до явного удаления или перезагрузки системы. После завершения скрипта ресурсы могут остаться в памяти.
- Необходимо отслеживать переполнение или зависания - добавлять тайм-ауты через
sem_acquire($sem, true)(с блокировкой) или семафор с флагом. - Настроить параметры
sem_get(права доступа, auto_release).
Используется в высокопроизводительных приложениях, работающих на одной машине, где важна скорость.
Как реализовать блокировку через таблицу базы данных?
Можно создать таблицу locks с уникальным ключом и использовать транзакции. Попытка вставить запись с тем же именем приведёт к ошибке.
// Предварительно создаём таблицу:
// CREATE TABLE app_locks (
// name VARCHAR(64) PRIMARY KEY,
// locked_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
// expires_at TIMESTAMP NULL
// );
$db = new PDO('mysql:host=localhost;dbname=test', 'user', 'pass');
$db->beginTransaction();
try {
$stmt = $db->prepare('INSERT INTO app_locks (name, expires_at) VALUES (?, ?)');
$expires = date('Y-m-d H:i:s', time() + 10);
$stmt->execute(['my_resource', $expires]);
echo "Блокировка установлена.\n";
// ... работа
sleep(5);
$db->query("DELETE FROM app_locks WHERE name='my_resource'");
$db->commit();
} catch (PDOException $e) {
$db->rollBack();
echo "Конфликт: блокировка уже занята.\n";
}
Проблемы:
- Необходимо чистить просроченные блокировки (по
expires_at) для избежания вечного захвата. - При разрыве соединения во время транзакции блокировка может сняться через откат, но не всегда.
- Подходит только для небольших нагрузок, так как конкуренция идёт за строку в таблице.
Простой вариант, если Redis недоступен, используется для задач, где точность не критична.
Как создать атомарную блокировку через систему файлов?
Использование mkdir() как атомарной операции: создание директории с уникальным именем (например, по PID). Если директория существует, значит, блокировка занята.
$lockDir = '/tmp/app_lock_' . md5('resource_name');
if (mkdir($lockDir, 0700)) {
echo "Блокировка через директорию установлена.\n";
// ... работа
sleep(3);
rmdir($lockDir); // освобождение
} else {
echo "Директория существует - блокировка занята.\n";
}
Недостатки:
- Нет автоматического тайм-аута. Если процесс упал, блокировка остаётся навсегда.
- Проблемы с производительностью на NFS (mkdir не атомарен на всех файловых системах).
- Необходимо обрабатывать ситуации частичного создания каталога (если процесс прерван).
Экзотический метод, применим в изолированных скриптах без дополнительных расширений.
Общие рекомендации:
- Всегда освобождайте блокировки в
finallyили используйте автоматическое истечение (TTL). - Избегайте блокировок на длительное время - старайтесь минимизировать критическую секцию.
- При распределённых блокировках используйте идентификатор владельца, чтобы не снять чужой замок.
- Мониторьте состояние блокировок для обнаружения зависших процессов.
Расширенные примеры реализации блокировок
Пример 1. Демонстрация файловой блокировки с конкурентными процессами
Создадим скрипт, который имитирует работу с общим счётчиком. Каждый процесс пытается увеличить счётчик, записанный в файл, под защитой flock.
// counter.php
$file = '/tmp/counter.dat';
$fp = fopen($file, 'c+');
if (flock($fp, LOCK_EX)) {
$counter = (int) fread($fp, 1024);
if (!$counter) $counter = 0;
$counter++;
rewind($fp);
ftruncate($fp, 0);
fwrite($fp, $counter);
echo "Новое значение: $counter\n";
flock($fp, LOCK_UN);
} else {
echo "Не удалось получить блокировку\n";
}
fclose($fp);
Запустим несколько параллельных процессов (в консоли):
# В трёх разных терминалах одновременно:
php counter.php
Результат (пример):
Новое значение: 1 Новое значение: 2 Новое значение: 3
Если убрать блокировку, значения могут повторяться из-за гонки.
Пример 2. Redis-блокировка с таймаутом и защитой от deadlock
Расширенный пример, использующий PHP-клиент Predis (Composer: predis/predis).
require 'vendor/autoload.php';
$client = new Predis\Client([
'scheme' => 'tcp',
'host' => '127.0.0.1',
'port' => 6379,
]);
$lockKey = 'process_lock:unique_id';
$token = bin2hex(random_bytes(16));
$ttl = 5; // секунд
$result = $client->set($lockKey, $token, 'NX', 'EX', $ttl);
if ($result) {
try {
echo "Работаем с блокировкой...\n";
sleep(2);
} finally {
$script = '
if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("del", KEYS[1])
else
return 0
end
';
$client->eval($script, 1, $lockKey, $token);
}
} else {
echo "Блокировка уже занята другим процессом\n";
}
Пример вывода при успехе или конфликте:
Работаем с блокировкой...
Пример 3. Семафоры System V с тайм-аутом
Используем sem_acquire с флагом 0 для неблокирующей проверки.
$key = ftok(__FILE__, 'x');
$sem = sem_get($key, 1, 0600, 1); // auto_release = 1, но не работает с неблокирующим захватом
// Неблокирующая попытка (флаг 0 – без ожидания)
if (sem_acquire($sem, false)) {
echo "Семафор захвачен мгновенно\n";
sleep(2);
sem_release($sem);
} else {
echo "Семафор занят, ждём...\n";
// Блокирующий захват с таймаутом? Нет, но можно эмулировать
$start = time();
$acquired = false;
while (time() - $start < 5) {
if (sem_acquire($sem, false)) {
$acquired = true;
break;
}
usleep(100000); // 100ms
}
if ($acquired) {
echo "Захвачено после ожидания\n";
sem_release($sem);
} else {
echo "Тайм-аут ожидания семафора\n";
}
}
sem_remove($sem); // очистка после использования
Результат (при конкуренции):
Первый запуск: Семафор захвачен мгновенно Второй запуск: Семафор занят, ждём... Захвачено после ожидания
Пример 4. MySQL GET_LOCK с использованием PDO
Покажем работу с именованными блокировками и освобождением.
$dsn = 'mysql:host=127.0.0.1;dbname=test;charset=utf8';
$pdo = new PDO($dsn, 'user', 'password');
$lockName = 'web_cron_lock';
// Получение блокировки с таймаутом 3 секунды
$stmt = $pdo->query("SELECT GET_LOCK('$lockName', 3) AS locked");
$data = $stmt->fetch(PDO::FETCH_ASSOC);
if ($data['locked'] == 1) {
echo "Блокировка MySQL успешно получена\n";
// ... выполнение критической операции
$pdo->query("SELECT RELEASE_LOCK('$lockName')");
} else {
echo "Блокировка не получена (занята или тайм-аут)\n";
}
Если запустить два скрипта одновременно, один получит 1, другой 0.
Скрипт 1: Блокировка MySQL успешно получена Скрипт 2: Блокировка не получена (занята или тайм-аут)