Реализация блокировок для многопоточных приложений на PHP

Раздел: Разработка на 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: Блокировка не получена (занята или тайм-аут)

блокировки в PHP - comments

En
Php lock (php)