Перехват управляющих сигналов в PHP. Graceful shutdown

версия для печати
Протест против SIGKILL :)

Речь про сигналы, передваемые в Linux любому процессу: SIGINT, SIGKILL и пр. Эти сигналы доступны только в nix-подобных системах, базовая фича таких ОС. На Windows не будет работать почти наверняка.

Я неправильно понимал, как работает, т.к. зашел не с той стороны. Сейчас разобрался, рассказываю.

Картинка кликабельна. Автор: Daniel StoriCC BY-NC-SA 4.0

Сигналы являются программными прерываниями, которые посылаются процессу, когда случается некоторое событие. Сигналы могут возникать синхронно с ошибкой в приложении, например SIGFPE (ошибка вычислений с плавающей запятой) и SIGSEGV (ошибка адресации), но большинство сигналов является. (с) OpenNET - Понятие о сигналах

Прим: под асинхронными тут понимаются сигналы, возникающие в произвольный момент времени, независимо от потока выполнения программы.

Полный список сигналов можно найти в Википедии. Есть еще сигналы POSIX, что как-то пересекается с сигналами UNIX, но так глубоко я уже не закапывался.

Перехват программных прерываний в PHP

В PHP для работы с управляющими сигналами есть расширение PCNTL. Но даже если мы ничего не будем писать на получение таких прерываний, запущенный скрипт все равно будет реагировать на них! Это меня сразу в тупик поставило. Зачем же тогда их слушать, когда и так все работает? А затем, чтобы написать какую-то реакцию в приложении на получение сигнала. Например, логировать факт остановки скрипта. Но это банально конечно :)

Тут уместна аналогия с исключениями: мы можем их ловить и как-то реагировать. А можем ничего не делать. И так же, как и с иключениями, можно продолжить выполнение программы, т.е. игнорировать полученный сигнал :o)

Сейчас мне интересны только SIGINT и SIGTERM. Это ассинхронные сигналы. Т.е. такой сигнал может прилететь в приложение в любой момент времени выполнения, что приведет к завершению приложения. И не важно, что там выполнялось в момент останова.

А теперь начинается самое интересное. Поскольку PHP не работает в несколько потоков, то проверять появление сигнала можно только между какими-нибудь действиями в основном коде. В древних версиях PHP (<=5.2) для этого использовалась директива declare(ticks = NN). Каждые NN низкоуровневых операций проверяем, есть ли управляющий сигнал.

Решение через тики было дорогим по времени выполнения. Поэтому в PHP 5.3 появилась возможность вручную проверять поступление сигналов. И это качественно изменило поведение программы, т.к. теперь реагировать на сигналы можно в тех местах кода, где мы этого хотим. Пример:

// Регистрируем функцию-обработчик на получение сигнала
pcntl_signal(SIGINT, static function() {
    exit(PHP_EOL . 'Interrupted' . PHP_EOL);
});

// Демонстрационный код
for ($i = 0; $i < 10; $i++) {
    echo date('h:i:s');
    sleep(2);
    echo ' pinned.' . PHP_EOL;
    // Разбор входящих сигналов
    pcntl_signal_dispatch();
}

Для проверки запустите этот код и нажмите Ctrl + C в любой момент его работы. Вероятный результат может быть таким:

vijit@dev: ~$ php some.php
10:51:39 pinned.
10:51:41 pinned.
10:51:43^C pinned.

Interrupted

Видите, после отправки сигнала (^C) программа все еще работала. Т.е. когда бы вы не потребовали остановиться, она выполнит требование только там, где разбирает поступившие сигналы.

Дальше жизнь стала еще легче, т.к. с PHP 7.1 ввели pcntl_async_signals(). Я с трудом понял смысл этой функции, даже прочитав RFC.

На Хабре нашел такое объяснение:

эта функция позволяет интерпретатору не отвлекаться на проверку сигнала, по сути она ставит наш обработчик сигнала следующим в стек вызова за выполняемой функцией.

Понятнее не стало. В справочнике PHP тоже написано так, что я не сразу понял смысл.

Суть в том, что данная функция заменила declare(ticks = NN), т.е. автоматическую обработку ассинхронных сигналов, что положительно сказалось на производительности и косвенно на удобстве использования. Но эта функция конфликтует по логике использования с pcntl_signal_dispatch(), потому что обработка сигнала опять стала выполняться в момент его получения, но не когда нам это удобно. Это я так красиво подошел к следующей теме..

Graceful shutdown

Есть такая нужда: выполняем сложное действие с кучей данных, а тут прилетает сигнал прекратить работу. Поскольку мы его получили, можем корректно закончить задачу и завершиться. А если бы не слушали управляющие сигналы, то скрипт мог бы прерваться посредине выполняемой задачи. Корректное завершение и называется graceful shutdown.

Как я сказал чуть выше, чтобы корректно завершить работу, нельзя использовать pcntl_async_signals(true) (внимание на TRUE в параметре), т.к. реакция на сигнал будет происходить в момент его получения приложением.

Ну и что с того, не используй и живи спокойно. И в нормальных движках это сработало бы, но в рассово-правильном стало проблемой. Там хардкодом зашита автоматическая реакция в конструкторе Symfony\Component\Console\SignalRegistry\SignalRegistry. И я не нашел документированного решения, как это поведение изменить.

Собственно, этот опус родился в результате раскопок, почему у меня не работает graceful shutdown в новой Symfony. Измение появилось где-то в версии 5.2 или выше.

Костылька очевидна: в нужном месте своего кода выключать автоматическую реакцию и проверять управляющие сигналы там, где мы к этому готовы по логике приложения.

Менее очевидный костыль: в обработчике сигнала только устанавливать некий флаг, что нужно завершить работу. А проверять этот флаг уже где-то в коде. Возможная реализация:

class SomeService
{
    private bool $mustShutdown = false;

    public function __construct()
    {
        pcntl_signal(SIGINT, function () {
            $this->mustShutdown = true;
        });
    }

    public function exec(): void
    {
        for ($i = 0; $i < 10; $i++) {
            echo date('h:i:s');
            sleep(2);
            echo ' pinned.' . PHP_EOL;
            if ($this->mustShutdown) {
                echo PHP_EOL . 'Interrupted' . PHP_EOL;
                return;
            }
        }
    }
}

pcntl_async_signals(true);
(new SomeService)->exec();

Мне такое решение не нравится по многим причинам: класс хранит состояние, что делает его объекты statefull; жесткая привязка реализуемой фичи на объект, где хранится флаг; подмена вызова pcntl_signal_dispatch() на проверку флага, что принципиально хуже; вынос реального обработчика за его пределы, что позволит не просто непредсказуемо изменить реализацию обработчика, но и в целом навешать на $mustShutdown массу логики в любом месте кода. Короче, это рабочее, но очень херовое решение, имхо.

Резюме

Изложение получилось субмурным. Попробую коротко:

  • Если нужно просто прерывать выполнение программы, дополнительно писать ничего не требуется. Само все работает.
  • Если нужна реакция на управляющий сигнал сразу при его получении, то через pcntl_signal() регистрируем обработчик сигнала и пишем в нем что-нибудь полезное. Далее разбор сигналов зависит от версии PHP:
    • до v7.1 прописываем в скрипте declare(ticks = 1) или вручную разбираем сигналы через вызов pcntl_signal_dispatch().
    • для версий >=7.1 включаем автоматическую обработку прерываний через pcntl_async_signals(true).
  • Если нужна реакция на управляющий сигнал, но она должна быть отложенная (graceful shutdown), то на всякий случай отключаем автоматическую обработку - pcntl_async_signals(false), потом регистрируем обработчик через pcntl_signal() и в нужном месте разбираем сигналы через pcntl_signal_dispatch(). Скорее всего в обработчике ничего не будет, т.к. тут сама суть в выборе момента останова, а не в реакции на сигнал.

Кстати, по непроверенным данным нельзя повлиять на SIGKILL. Приложение будет остановлено немедленно. Я думаю, так сделано на случай корявого кода, когда другие прерывания игнорируются.

[1oo%, EoF]

Понравилась статья? Расскажите о ней друзьям:

Метки: PHP, кодинг

Комментарии

Показать/скрыть правила
Имя
[i] [b] [u] [s] [url]
:-) ;-) :D *lol* 8-) :-* :-| :-( *cry* :o :-? *unsure* *oops* :-x *shocked* *zzz* :P *evil*

Осталось 1000 символов.
Код защиты от спама Обновить код
Каждый комментарий проходит ручную модерацию. 100% фильтрация спама.
Для работы модуля комментариев включите javaScript

Продвижение
Время
Метки
Щелкни мышей, чтобы закрыть