PHP
#bitrix#agent#cron#php#lock#flock#concurrency

Cron/Agent: шаблон lock-файла для защиты от параллельного запуска

Шаблон агента или cron-задачи с файловой блокировкой (flock) для предотвращения параллельного выполнения на одной ноде.

Как использовать

  1. Скопируйте шаблон функции с flock(LOCK_EX | LOCK_NB), подставьте путь к lock-файлу (например /upload/locks/имя_агента.lock) и свою логику в try.
  2. В finally обязательно вызовите flock(LOCK_UN) и fclose(); при необходимости удалите lock-файл.
  3. Зарегистрируйте агента через CAgent::AddAgent() или в админке (Настройки → Агенты).

Агенты и cron-задачи в 1С-Битрикс могут вызываться чаще, чем успевает завершиться предыдущий запуск. Без блокировки два процесса выполняют одну и ту же тяжёлую операцию (импорт, синхронизация, рассылка) параллельно — дублирование данных, гонки, нагрузка на БД. Проблема: агент запускается по расписанию каждые N минут, а выполнение занимает больше N минут. Симптомы: двойная обработка одних и тех же записей, ошибки уникальности в БД, высокий CPU. Ниже — шаблон с файловой блокировкой через flock(): при занятой блокировке следующий запуск сразу выходит без выполнения; плюс вариант с проверкой по времени и команды для проверки.

Решение

Шаблон агента или cron-задачи с файловой блокировкой через flock() для защиты от параллельного запуска. Используйте для долгих задач, импортов, синхронизаций, которые не должны выполняться одновременно.

use Bitrix\Main\Application;

/**
 * Шаблон агента с файловой блокировкой
 * @return string Строка для повторного запуска агента
 */
function MyLongRunningAgent() {
    $lockFile = $_SERVER['DOCUMENT_ROOT'] . '/upload/locks/my_agent.lock';
    $lockHandle = @fopen($lockFile, 'c+'); // c+ - создаёт файл если нет
    
    if (!$lockHandle) {
        // Не удалось открыть файл, пропускаем выполнение
        return "MyLongRunningAgent();";
    }
    
    // Пытаемся получить эксклюзивную блокировку (non-blocking)
    // LOCK_EX - эксклюзивная блокировка
    // LOCK_NB - non-blocking (не ждём, если файл заблокирован)
    if (!flock($lockHandle, LOCK_EX | LOCK_NB)) {
        // Файл уже заблокирован другим процессом
        fclose($lockHandle);
        return "MyLongRunningAgent();"; // Пропускаем выполнение
    }
    
    // Записываем PID процесса в lock-файл (опционально, для отладки)
    ftruncate($lockHandle, 0);
    fwrite($lockHandle, getmypid());
    fflush($lockHandle);
    
    try {
        // ===== ВАША ЛОГИКА АГЕНТА =====
        
        // Пример: обработка элементов
        $processed = 0;
        $limit = 100;
        
        // Ваш код здесь
        // ...
        
        // ===== КОНЕЦ ЛОГИКИ =====
        
    } catch (\Exception $e) {
        // Логируем ошибку
        if (class_exists('SafeFileLogger')) {
            $logger = new SafeFileLogger('agents.log');
            $logger->error('Agent error', [
                'agent' => 'MyLongRunningAgent',
                'error' => $e->getMessage(),
                'trace' => $e->getTraceAsString()
            ]);
        }
    } finally {
        // ВАЖНО: всегда освобождаем блокировку в finally
        flock($lockHandle, LOCK_UN);
        fclose($lockHandle);
        // Опционально: удаляем lock-файл
        @unlink($lockFile);
    }
    
    // Возвращаем строку для повторного запуска
    return "MyLongRunningAgent();";
}

/**
 * Альтернатива: проверка через время последнего запуска
 */
function MyAgentWithTimeCheck() {
    $lockFile = $_SERVER['DOCUMENT_ROOT'] . '/upload/locks/my_agent_time.lock';
    $lockTimeout = 300; // 5 минут
    
    // Проверяем время последнего запуска
    if (file_exists($lockFile)) {
        $lastRun = filemtime($lockFile);
        if (time() - $lastRun < $lockTimeout) {
            // Ещё не прошло достаточно времени или выполняется
            return "MyAgentWithTimeCheck();";
        }
    }
    
    // Создаём/обновляем lock-файл
    touch($lockFile);
    
    try {
        // Ваша логика
        // ...
        
    } finally {
        // Обновляем время последнего запуска
        touch($lockFile);
    }
    
    return "MyAgentWithTimeCheck();";
}

Регистрация агента:

// Регистрация агента
CAgent::AddAgent(
    "MyLongRunningAgent();",
    "",
    "N",
    3600, // Интервал в секундах
    "",
    "Y",
    "",
    100
);

// Или через админку: Настройки -> Настройки продукта -> Агенты

flock() действует только на одной ноде. Для кластера используйте блокировку через БД (например GET_LOCK в MySQL). Всегда снимайте блокировку в finally. Lock-файлы храните в каталоге с правами на запись (например /upload/locks/). PID в файле помогает отлаживать зависшие процессы.

Проверка

  1. Проверить, что lock-файл создаётся при запуске — после первого запуска агента (по расписанию или вручную через «Выполнить агента» в админке):
ls -la /path/to/site/upload/locks/
cat /path/to/site/upload/locks/my_agent.lock

В файле должен быть PID процесса. Пока агент выполняется, файл заблокирован.

  1. Диагностика: второй запуск не выполняет логику — запустите агент вручную дважды подряд (или дождитесь двух срабатываний по cron). В логах или по побочному эффекту (например запись в БД) должен быть только один результат выполнения; второй запуск выходит по flock(LOCK_EX | LOCK_NB) без выполнения тела.

  2. Проверка зависшего lock — если процесс упал без снятия блокировки, lock освободится при закрытии файла ядром. Если lock «завис» из-за долгого процесса, посмотрите PID в lock-файле: ps -p PID (под Linux). При необходимости завершите процесс и удалите lock-файл вручную.

Типичные ошибки

  • Блокировка не снимается при исключении — без finally при выбросе исключения в try блокировка остаётся, следующие запуски всегда будут пропускать выполнение. Всегда вызывайте flock($handle, LOCK_UN) и fclose($handle) в finally.
  • Папка для lock-файлов недоступна для записи — создайте /upload/locks/ и выставите права (например 755 или 775 в зависимости от пользователя PHP). Иначе fopen вернёт false и агент будет каждый раз выходить без выполнения.
  • Кластер или несколько воркеровflock() на одном сервере не видна на другом. Для распределённой среды нужна блокировка в БД (GET_LOCK/RELEASE_LOCK в MySQL или аналог). См. Идемпотентный агент с блокировкой при необходимости блокировки через БД.
  • LOCK_NB не указан — без LOCK_NB вызов flock(LOCK_EX) будет ждать освобождения файла. Для агента обычно нужен non-blocking режим: не ждать, а сразу выйти и запланировать следующий запуск.

Где применять

  • Prod: агенты Bitrix (импорт, синхронизация с внешними системами, тяжёлые отчёты), cron-скрипты PHP на одной ноде.
  • Dev: тот же шаблон для локального теста агентов; убедитесь, что путь к lock-файлу доступен для записи.

Связанные сниппеты: Идемпотентный агент с блокировкой, Безопасная ротация логов.