Назад в блог

File-lock через mkdir в PHP: как я решаю параллельность cron без Redis и mutex

Мне нужно было, чтобы 13 cron-задач никогда не запускались параллельно. Все они работают с одним Chrome-профилем — той же сессией LinkedIn, которую я залогинил руками месяц назад. Два параллельных инстанса и профиль умирает.

Первая мысль: Redis SETNX с TTL. Поставил паузу. Зачем поднимать Redis только ради lock'а? Это означает отдельный сервис, отдельный мониторинг, отдельная точка отказа — на задаче, где Redis вообще не нужен ни для чего другого.

Решение оказалось проще: mkdir cron/.lock.

Почему Redis — не всегда правильный ответ на конкурентность

Redis SETNX — нормальный выбор, когда Redis уже в стеке. Если у вас Bitrix с Redis как хранилищем сессий и cache-бэкендом, добавить SETNX на lock — ноль дополнительных затрат.

Но если Redis не нужен ни для чего другого, вы добавляете сервис ради одной атомарной операции. То же самое с sem_get() — зависит от расширения sysvsem, которого может не быть на продакшне. flock() не надёжен поверх NFS, и это ограничение, которое всплывает в самый неподходящий момент.

Для большинства одиночных серверов с cron-задачами правильный вопрос: «Что гарантирует атомарность на уровне ОС, без зависимостей?»

Ответ: файловая система.

mkdir как атомарная операция

mkdir на Linux и macOS — атомарен по POSIX. Это не «почти атомарен» или «работает в большинстве случаев». Либо директория создалась, либо нет. Третьего состояния нет.

Если два процесса одновременно вызывают mkdir("cron/.lock"), ровно один из них получит успех. Второй получит ошибку EEXIST — и это гарантировано ядром, а не нашим кодом.

Это именно то, что нужно для cron-lock: дешёвый примитив, который работает на любом сервере с файловой системой.

Полная реализация

<?php

$lockDir = __DIR__ . '/cron/.lock';
$pidFile = $lockDir . '/pid';

function acquireLock(string $lockDir, string $pidFile): bool
{
    // Проверяем: жив ли предыдущий процесс
    if (is_dir($lockDir) && file_exists($pidFile)) {
        $pid = (int) file_get_contents($pidFile);
        // posix_kill(..., 0) не убивает — только проверяет существование процесса
        if ($pid > 0 && posix_kill($pid, 0)) {
            return false; // хозяин жив, выходим
        }
        // Хозяин мёртв — сносим lock
        @unlink($pidFile);
        @rmdir($lockDir);
    }

    if (!@mkdir($lockDir, 0755)) {
        return false; // кто-то успел раньше
    }

    file_put_contents($pidFile, getmypid());
    return true;
}

function releaseLock(string $lockDir, string $pidFile): void
{
    @unlink($pidFile);
    @rmdir($lockDir);
}

// --- Старт ---

if (!acquireLock($lockDir, $pidFile)) {
    // Второй инстанс — выходим чисто
    file_put_contents('cron/logs/T01.jsonl',
        json_encode(['ts' => date('c'), 'status' => 'skipped_locked']) . "\n",
        FILE_APPEND
    );
    exit(0);
}

register_shutdown_function('releaseLock', $lockDir, $pidFile);

// Основная логика задачи...

Функция acquireLock делает три вещи. Проверяет, есть ли уже lock-директория с PID. Если есть, проверяет жив ли процесс через posix_kill($pid, 0). Если процесс мёртв, сносит lock и захватывает заново. register_shutdown_function гарантирует освобождение при любом завершении, включая exit() и некритические ошибки PHP.

Crash-recovery через PID

Главное, чего не хватает в простом mkdir-lock: что если процесс умрёт? Директория останется, следующий запуск увидит её и откажется запускаться навсегда.

PID-файл внутри lock-директории решает это. При старте следующей задачи:

  1. Директория существует — проверяем PID.
  2. posix_kill($pid, 0) возвращает false — процесс мёртв.
  3. Сносим lock, создаём новый, продолжаем.

posix_kill($pid, 0) не посылает никакого сигнала. Это способ ядра сказать: «Процесс с таким PID существует». При false — не существует. При true — существует и работает.

В моей системе за неделю работы это сработало дважды: один раз Claude Code завис и был убит вручную, один раз cron-задача упала с фатальной ошибкой PHP. Оба раза следующий тик подхватил lock без ручного вмешательства.

STOP-файл: kill-switch для всей системы

В корне 06 - Операции/cron/ лежит пустой файл STOP. Каждый wrapper-скрипт проверяет его в первой строке:

if (file_exists(__DIR__ . '/STOP')) {
    error_log('Cron stopped via STOP file');
    exit(0);
}

Одна команда останавливает все 13 задач:

touch "06 - Операции/cron/STOP"

Зачем? Если LinkedIn показывает security alert или капчу, мне нужно остановить всё быстро. Быстрее, чем launchctl unload тринадцати plist-файлов. Быстрее, чем kill по PID-ам. Один файл, одна проверка.

Когда этот подход не подходит

Три ситуации, где mkdir-lock не даёт нужных гарантий.

NFS-монтирование: атомарность mkdir зависит от реализации клиента и сервера. Пишете с нескольких серверов на NFS? Нужен явный distributed lock, например Redis с Redlock-алгоритмом.

Docker volumes с bind-mount: на одном узле всё работает нормально, overlay filesystem поддерживает POSIX-семантику. Bind-mount из хоста на разных Docker-узлах эту гарантию не даёт.

Несколько машин: если cron-задачи могут стартовать на разных серверах одновременно, filesystem-based lock не поможет. Нужен сетевой примитив.

Для одиночного сервера с файловым хранилищем — работает надёжно.

Итог

Для одного сервера, одного PHP-процесса и задачи «не допустить параллельный запуск» — mkdir с PID-файлом решает всё. Без Redis. Без внешних зависимостей. Атомарность гарантирована POSIX. Crash-recovery встроен. Kill-switch: один файл.

Дёшево, скучно, работает в production.

Применяю это для ночной переиндексации Elasticsearch, импорта из 1C и cache-warming Bitrix. Паттерн тот же. Полный контекст, откуда взялась эта система, описан в статье про 13 cron-задач для LinkedIn SSI. Если надо, в DM покажу конкретную реализацию под Bitrix-контекст.