На просторах интернета, в том числе и Хабра, неоднократно поднималась тема обработки сигналов с помощью средств php, но в своем большинстве они достаточно старые, содержат неактуальную информацию, и не отвечают на часто задаваемый вопрос: «зачем?», вот с него мы и начнем.



В каких случаях нам может понадобиться обработка сигналов в php?


  • В любом нагруженном проекте рано или поздно приходится столкнуться с необходимостью распараллелить процесс и самый частый способ — это воспользоваться сервером сообщений, таким как RabbitMq, Gearman, Kafka и прочие. В этот момент появляется необходимость создать так называемый консьюмер. Он состоит из цикла, проверяя в нем наличие новых сообщений и обрабатывая их.

    Теперь собственно ситуация: мы обновили код и нужно перезапустить консьюмеры — если просто отправить сигнал SIGTERM, то будет вероятность получить не консистентность данных в БД или другие проблемы по причине обрыва скрипта посреди обработки, в этом случае нам и поможет обработка сигналов.
  • Также в эру контейнеризации неплохо уметь корректно тушить контейнер с приложением не потеряв обрабатываемой информации.
  • Ну и третий вариант — специфические задачи где нужен демон и его написали на php, тут нам пригодится ряд сигналов для обновления конфигурации, завершения скрипта, прочих действий. Пример.



Арсенал который нам предоставляет php для работы с сигналами:

  • pcntl_signal() php >= 4.1.0 — функция для регистрации обработчика сигнала.
  • declare(ticks = 1) php < 5.3 — указывает раз в сколько тиков интерпретатор будет проверять наличие сигнала.
  • pcntl_signal_dispatch() php >= 5.3.0 — ручной запуск проверки наличия необработанного сигнала, как более производительная альтернатива declare.
  • pcntl_async_signals() php >= 7.1.0 — асинхронная подстановка обработчика сигнала в стек вызовов.
  • pcntl_signal_get_handler() php >= 7.1.0 — получение функции обработчика сигнала.
  • pcntl_alarm() php >= 4.3.0 — отправить себе SIGALARM.
  • pcntl_sigprocmask() php >= 5.3.0 — можно заблокировать, разблокировать обработку заданных сигналов, также удалить, заменить стек заблокированных сигналов.



Немного теории:


Для каждого сигнала который мы хотим обрабатывать нужно зарегистрировать функцию обработчик через pcntl_signal().

Важное замечание из php.net pcntl_signal() — не собирает обработчики сигналов в стек, а заменяет их, то есть если вы где-то в коде еще раз определите обработчик какого-то сигнала, то он просто перекроет предыдущий, именно по этому в сторонних библиотеках не используют стараются не использовать эти возможности. Но в конце статьи будет небольшой бонус по этому поводу.

После выполнения функции обработчика интерпретатор продолжает свою работу с места прерывания. Если вы конечно не вызвали die() в обработчике.

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

Синтетический тест производительности не показал отличий с использованием pcntl_async_signals() и без нее.

Теперь поговорим про ограничения обработчика, сигнал обработается только после окончания выполнения текущей функции, если это обращение к api или БД то время до обработки сигнала может затянутся, это нужно учитывать, например если вы пользуетесь супервизором или докером увеличьте таймаут до отправки SIGKILL на время вашего самого длительного блокирующего вызова плюс запас. Также хочу сказать что в момент тестирования столкнулся с интересным поведением функции sleep(), как оказалось документированным, но не ожидал — она прерывается сигналом и возвращает количество секунд которое недоспала то-есть если вдруг вам понадобится ее использовать и быть уверенным в длине сна то это будет выглядеть так:

$sleep = 1000;
while ($sleep > 0) {
    $sleep = sleep($sleep);
}

Хочу так же акцентировать внимание на том что вы встретите на многих ресурсах примеры с обработкой SIGKILL — но это не работает(работало на более старых версиях linux), сейчас же этот сигнал убивает процесс со стороны операционной системы, и на это повлиять нельзя.


И немного кода


Как это будет выглядеть в случае с косьюмером для RabbitMq:
Это наш упрощенный менеджер задача которого подготовить консьюмер и следить выполнением задач


class QueueManager
{
    private $stopConsume = false;

    public function stopConsume()
    {
        $this->stopConsume = true;
    }

    public function consume($consumerAlias)
    {
        $consumer = $this->getConsumerBuilder()->create($consumerAlias);
        $channel = $consumer->getChannel();

        while (\count($channel->callbacks) && $this->stopConsume !== true) {
            $channel->wait();
        }
    }
}

А это все что вам понадобится в контроллере:


class SomeController
{
    private $queueManager;

    public function __construct()
    {
        $this->queueManager = new QueueManager();
        pcntl_signal(SIGTERM, [$this->queueManager, 'stopConsume']);
    }

    public function consumeSomeQueue()
    {
        $this->queueManager->consume('SomeConsumer');
    }
}

При получении сигнала будет вызван метод stopConsume объекта queueManager — он в свою очередь присвоит параметру stopConsume значение true и останется только дойти до конца обработки текущего сообщения, после чего цикл закончится.

Целью статьи не является рассмотрение побочных вопросов демонизации процессов таких как поддержания соединений, утечек памяти и т.д

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

Собственно сам бонус
Такого формата класс даст нам возможность ставить несколько обработчиков на сигнал, если спросите зачем — просто потому что можем.


class SigHandler
{
    private $handlers = [];

    public function handle($sigNumber)
    {
        if (!empty($this->handlers[$sigNumber])) {
            foreach ($this->handlers[$sigNumber] as $signalHandler) {
                $signalHandler($sigNumber);
            }
        }
    }

    public function subscribe($sigNumber, $handler)
    {
        $this->handlers[$sigNumber][$this->getFunctionHash($handler)] = $handler;
    }

    public function unsubscribe($sigNumber, $handler)
    {
        unset($this->handlers[$sigNumber][$this->getFunctionHash($handler)]);
    }

    private function getFunctionHash($callable)
    {
        return spl_object_hash($callable);
    }
}

Попробовать в работе:


pcntl_async_signals(true);
$sigHandler = new SigHandler();
pcntl_signal(SIGTERM, [$sigHandler, 'handle']);
$sigHandler->subscribe(SIGTERM, function () {
    echo 'sigterm_1', PHP_EOL;
});
$sigHandler->subscribe(SIGTERM, function () {
    echo 'sigterm_2', PHP_EOL;
});

while (true) {
}

Комментарии (7)


  1. kovserg
    08.05.2018 10:35

    Думал сейчас увижу как на PHP цифровые фильтры делают и БПФ используют. Ан нет.

    У скриптов есть еще засада: бывает они зависают и приходится watch dog-и использовать.
    Например если скрипт вызывать по расписанию, то в один прекрасный момент он может зависнуть даже при наличии set_time_limit. Раз в несколько месяцев обязательно. Так что приходится сигналить руками:

    примерная реализация
    class CheckProcess {
      var $pid_file="process.pid";
      var $time_limit=1860; // 31min
      function start($s) {
        if ($s) {
          file_put_contents($this->pid_file,getmypid());
        } else {
          if (file_exists($this->pid_file)) {
            $t1=filemtime($this->pid_file);
            $t2=time();
            $dt=$t2-$t1;
            if ($dt>$this->time_limit) {
              $pid=file_get_contents($this->pid_file);
              $pid=intval($pid,10);
              if ($pid) posix_kill($pid,SIGKILL);
              unlink($this->pid_file);
            }
          }
        }
      }
    }
    class FileMutex {
      var $fh;
      var $filename="process.mutex";
      var $locked=false;
      function __destruct() { $this->unlock(); }
      function lock() {
        $this->fh=fopen($this->filename,"w+");
        if (!$this->fh) throw new Exception("create file $this->filename");
        $this->locked=flock($this->fh,LOCK_EX|LOCK_NB);
        return $this->locked;
      }
      function unlock() {
        if ($this->locked) flock($this->fh,LOCK_UN);
        if ($this->fh) { fclose($this->fh); unset($this->fh); }
      }
    }
    


    1. Vladnev Автор
      09.05.2018 13:23

      Жаль что пришлось вас огорчить, для этих целей наверное обходил бы php стороной :)
      С зависанием сталкиватся не приходилось, я думаю причина может быть или в утечках или с алокацией памяти, этим могут болеть сторонние расширения.
      Скрипты раньше работали на php 5.4 и кронах, сейчас на php 7.1 где-то supervisor где-то еще кроны.


  1. 0x9d8e
    08.05.2018 13:09

    Оффтоп, но я на это как-то кучу времени на эту ерунду потратил, может кому пригодится:
    Есть ещё полезное применение сигналов и статусов завершения: таймаут для внешней команды. Вызывая внешнюю команду, вначале добавляем, например timeout 40 --kill-after=60 ..., после 40 секунд внешнему процессу будет передан sigterm, а если через 60 он всё ещё не закроется, то sigkill. Если ваша команда закрыта по таймауту, то вернётся статус 128, иначе статус, который она вернула. См man timeout.
    Это единственный нормальный способ сбрасывать подвисшие команды из php, который я смог придумать, не считая всяких извращений. Спасло, когда в кликхаус-клиенте иногда навсегда зависали запросы, а его собственный таймаут не срабатывал.


    1. kovserg
      08.05.2018 14:29

      Еще прикольнее когда команда нафоркала кучу исполнителей, а после её смерти они продолжают фтыривать.


    1. 0x9d8e
      08.05.2018 14:42

      Поправочка, не 128, а 124.


  1. evgwed
    09.05.2018 13:24

    Подход, где один скрипт php отправляет другому php скрипту команду для обновления состояния будет действовать только в рамках одного сервера/виртуалки/контейнера. Но как быть, если два воркера должны общаться друг с другом, но они изолированы друг от друга, например, в разных docker?


    1. Vladnev Автор
      09.05.2018 13:31

      Если я правильно понял вопрос – то для docker немного другой подход, то что в контейнере не должно обновляться.
      Подымаем новый контейнер, а старый тушим (и туда идет сигнал чтобы получилось grasefull shutdown).
      А если вам нужно реализовать прямое общение нескольких скриптов, в разных контейнерах, я бы смотрел на rest, jsonapi, socets.