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-директории решает это. При старте следующей задачи:
- Директория существует — проверяем PID.
posix_kill($pid, 0)возвращаетfalse— процесс мёртв.- Сносим 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-контекст.