Доброго времени суток, сегодня я буду описывать довольно забавную задачку, из области мало связанной напрямую с web-программированием, а точнее создание демона на PHP. Понятное дело что первым вопросом будет: «А зачем это надо?» Ну что ж, будем разбираться последовательно.


Казалось бы ведь редкое извращение писать программы такого рода на языках вроде PHP, но что если возникает необходимость непрерывно отслеживать или обрабатывать какой-либо непрерывный или не регулярный процесс, да и скриптик нужен небольшой. Как правило, под рукой не оказывается грамотного специалиста способного не прострелить себе ногу с помощью C++ или не отрезать себе ногу с помощью C, ну или просто хорошего прикладного программиста. В этих случаях каждый крутится как может и тут появляются самые разнообразные химеры и гибриды, вроде скриптов запущенных с параметром set_time_limit(0), скрипты стартующие с помощью cron каждую секунду (да, да видел и такое) и прочие, не менее костыльные вещи.

Собственно о постановке задачи. В процессе работы над проектом появилась необходимость интеграции со сторонним комплексом программ. Единственный способ связи с программулиной — общение по средствам его собственного протокола через сетевой порт, ждать наступления события, разобрать ответ, обработать, сохранить в базу. Казалось бы ничего сложно, написать скриптик с бесконечным циклом, внутри которого бы происходила вся необходимая магия и вуаля! В самом начале я рассуждал примерно так же, но вскоре выяснилось что данный подход имеет существенный недостаток, один из которых это некорректные данные, появляющиеся в момент смерти скрипта. Сервер перезагружается и в базе остаются данные, которые не успели ни обработать, ни удалить. Неприятно, очень неприятно.

Ну что ж, давайте разбираться, у нас есть классический LAMP на CentOS, сторонняя софтина и сильное желание не прикручивать какие-нибудь другие инструменты или уж «боже упаси программировать на C». Я посчитал что было бы совсем не плохо если исходный скрипт можно было бы научить распознавать сигналы операционный системы с тем чтобы он завершал свою работу корректно. Для тех кто не знает, в общих чертах, Linux управляет процессами с помощью сигналов, которые говорят процессу как он должен себя вести. При получении такого сигнала процесс должен изменить своё поведение или не делать ничего, если это не требуется для его работы. Лично для меня наиболее интересен сигнал SIGTERM. Этот сигнал говорит о том что процесс должен завершить свою работу. Список всех существующих сигналов можно посмотреть тут:
Сигналы UNIX

Так же имеется и другая особенность, каждый процесс в Linux так или иначе связан с терминалом откуда он был запущен и от него же наследует потоки вводы/вывода, по этому как только вы закроете терминал, в котором запустили скрипт он тут же завершит своё выполнение. Для того чтобы избежать такой ситуации нужно создать дочерний процесс, сделать его основным, прикончить родителя и отвязать оставшийся процесс от ввода/вывода терминала, в котором он был запущен. Согласен, звучит сложно, запутанно и не понятно, но на практике всё значительно проще чем выглядит.

Что нам нужно для работы? В принципе не так много, собственно сам PHP, в моём случае этот PHP5.6 и несколько расширений:


Теперь, когда у нас есть всё что нам нужно, приступим к написанию кода.

Как было сказано выше, для начала работы нашего демона, в соответствии с правилами работы подобных программ в Linux, его нужно отвязать от терминала, в котором его запустили для этого нужно воспользоваться функцией pcntl_fork(), она создаёт дочернюю копию текущего процесса и возвращает его числовой id в случае успеха. Ну и конечно прикончить родительский процесс.

<?php
// Создаем дочерний процесс
$child_pid = pcntl_fork();

if( $child_pid ) {
    // Выходим из родительского процесса, привязанного к консоли...
    exit(0);
}

Теперь нужно сделать наш процесс самостоятельным и объяснить системе, что мы сами отвечаем за себя.

//  Делаем основным процессом дочерний...
posix_setsid();

Таким образом, операционная система будет знать что мы способны определять своё поведение и поместит pid нашего процесса в очередь для получения системных сигналов.

Теперь нас ждёт самое интересное — нужно определить как именно мы будем работать и взаимодействовать с операционной системой. Я решил вынести этот функционал в отдельный класс, на тот случай если этот код ещё понадобится. Приступим, для начала необходимо определиться что что класс должен уметь делать.

Получать и обрабатывать сигналы операционной системы;
Уметь понимать запущен ли демон или нет;
Запускать задачу необходимую для демонизации;
Знать когда нужно остановиться;

Для реализации этих задач стоит разобрать функции, которые нам пригодятся. Функция pcntl_signal(), нужна для того чтобы назначить функцию обработчик для сигнала. Принимает в качестве аргументов: сигнал, для которого назначается обработчик и функцию или метод класса отвечающего за обработку сигнала. Функция getmypid(), которая возвращает pid текущего процесса. Ну и наконец функция posix_kill(), отправляющая сигнал указанному процессу, принимает два аргумента: pid процесса которому нужно отправить сигнал и собственно сам сигнал, который нужно отправить.

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

<?php

class Daemon
{
    protected $stop = false;

    protected $sleep = 1;

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

//  Метод занимается обработкой сигналов
    public function signalHandler($signo) {
        switch($signo) {
            case SIGTERM:
                //  При получении сигнала завершения работы устанавливаем флаг...
                $this->stop = true;
                break;
            //  default:
            //  Таким же образом записываем реакцию на любые другие сигналы если нам это нужно...
        }
    }

Как видите метод принимает в качестве аргумента сигнал, который ему отправляется и в зависимости от того какой сигнал отправлен демону производит те или иные действия.

Теперь нам нужно точно узнать запущен ли наш демон или нет. Как бы так нам это сделать, ведь демон может быть запущен из разных терминалом или несколько раз подряд. Если несколько экземпляров одного и того же скрипта будут пытаться одновременно получить доступ к одним и тем же ресурсам, думаю это будет крайне неприятно. Чтобы избежать такой ситуации, мы можем воспользоваться старым как мир способом, в ходе выполнения программы создать файл в определённом месте с записанным туда pid процесса программы и удалять его каждый раз когда наше приложение закрывается. Таким образом проверяя на существование этот файл мы сможем знать есть ли запущенные копии нашего приложения. Для решения этой задачи определим метод нашего класса.

//  Собственно детальная проверка что происходит с демоном, жив он или мёрт и как так получилось...
    public function isDaemonActive($pid_file) {
        if( is_file($pid_file) ) {
            $pid = file_get_contents($pid_file);
            //  Проверяем на наличие процесса...
            if(posix_kill($pid,0)) {
                //  Демон уже запущен...
                return true;
            } else {
                //  pid-файл есть, но процесса нет...
                if(!unlink($pid_file)) {
                    //  Не могу уничтожить pid-файл. ошибка...
                    exit(-1);
                }
            }
        }
        return false;
    }

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

Самое время подумать, а как же мы будем проделывать собственно те самые операции, ради которых всё и задумывалось? Вариантов реализации тут много. В моём случае нужно было ждать результатов от сторонней службы, без этих данных сам процесс был бесполезен и никаких действий над уже имеющимися данными или ресурсами не производил, поэтому я реализовал всю обработку в функции, которая получала или не получала от сторонней службы данные. Если данных не было, функцию следовало вызывать до тех пор пока они не появятся. Таким образом написанный мною метод класса, реализующий полезную нагрузку зависел от двух параметров: внутреннее состояние демона и результаты работы функции обрабатывающей данные от сторонней службы.

//  С помощью этого метода мы определяем работаем ли мы дальше или останавливаем процесс...
    public function run($func)
    {
        //  Запускаем цикл, проверяющий состояние демона...
        while(!$this->stop){
            do{
                //  Выполняем функцию несущую полезную нагрузку...
                //  Получаем результат её работы...
                $resp = $func();

                //  Если результаты есть, то ждём установленный таймаут...
                if(!empty($resp)){
                    break;
                }
                //  Если результатов нет, то выполняем её повторно...
            }while(true);

            sleep($this->sleep);
        }
    }

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

Теперь наступает кульминационный момент, на нужно всё это связать вместе и запустить, думаю самое время написать конструктор для нашего класса.

//  В конструкторе мы задаём где демон будет хранить свой pid и с какой задержкой мы будем выполнять функцию несущую полезную нагрузку...

    //  Я решил установить значения по умолчанию, мало ли что...
    public function __construct($file = '/tmp/daemon.pid',$sleep=1)
    {
        //  Проверяем не запущен ли наш демон...
        if ($this->isDaemonActive($file)) {
            echo "Daemon is already exsist!\n";
            exit(0);
        }

        $this->sleep = $sleep;

        //  Назначаем метод, который будет отвечать за обработку системных сигналов...
        pcntl_signal(SIGTERM,[$this,'signalHandler']);

        //  Получаем pid процесса с помощью встроенной функции getmypid() и записываем его в pid файл...
        file_put_contents($file, getmypid());
    }

Проверяем с помощью файла запущен ли процесс или нет, если запущен то выводим соответствующее предупреждение, устанавливаем задержку, назначаем обработчики сигналов (в данном случае только один), создаём файл и записываем туда свой pid, чтобы знать другие копии процесса знали что мы уже работаем. С классом мы закончили.

Теперь возвращаемся к написанию самого скрипта демона. Мы остановились на том что закончили все приготовления для запуска демона.

//  Здесь я подключаю всякую нужную штуку...
include(__DIR__.'/Daemon.php');
include(__DIR__.'/ExampleClass.php');

//  Класс изображающий полезную нагрузку...
$example = new ExampleClass();

//  Именно эта функция делает всякую полезую нам нагрузку, которую мы хотим демонизировать...
//  Если нам нужны какие-нибудь классы не забываем упомянуть их тут в противном случае простоо не получите к ним доступ...
$func = function() use ($example){

    // Тут живёт всякая полезная нагрука...
    $example->test();

    return true;
};

//  Собственно создаём демона, соответственно говорим ему куда записывать свой pid...
$daemon = new Daemon('/tmp/daemon.pid');

//  Закрываем порочные связи со стандартным вводом-выводом...
fclose(STDIN);
fclose(STDOUT);
fclose(STDERR);

//  Перенаправляем ввод-вывод туда куда нам надо или не надо...
$STDIN = fopen('/dev/null', 'r');
$STDOUT = fopen('/dev/null', 'wb');
$STDERR = fopen('/dev/null', 'wb');

//  Запускаем функцию несущую полезную нагрузку...
$daemon->run($func);

Мы подключаем все библиотеки, которые нам нужны, в том числе и файл с нашим классом Daemon.php, описываем функцию, которая будет выполнять полезную нагрузку, создаём экземпляр класса Daemon с нужными нам параметрами, отвязываем стандартный ввод/вывод от текущего терминала и перенаправляем их в /dev/null (если мы сделали бы это раньше, то рисковали бы не увидеть сообщения об ошибках в процессе выполнения скрипта), передаём методу run класса Daemon, нашу функцию, которая будет выполняться демоном.

На этом всё. Наш демон работает и прекрасно общается с ОС. Все хорошего и удачи.

P.S. Исходные коды заготовки для демона доступны тут: https://github.com/Liar233/php-daemon/.

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


  1. Borro
    07.02.2016 12:06
    +1

    Есть отличное расширение для создания демонов на PHP arara/process


  1. zelenin
    07.02.2016 14:23
    +1

    Жаль не на PHP6…
    А если серьезно, то и на 5-й версии работать не будет. 5.3+ как минимум после беглого взгляда.
    Код-стайл никакой не применяется, весь код написан под влиянием сиюминутной страсти. PSR, Composer — нет, не слышал. Минимальный тайп-хинтинг хотя бы на до-php7 уровне — лишнее. Велосипедам — да! Нести 20 смысловых строчек на хабр — да!


  1. dolphin4ik
    07.02.2016 14:27
    -1

    Думаю сейчас будет много холиваров… Но демоны на пхп это всегда палка о двух концах. Но в данном случае чем больше попыток и вариантов, тем лучше. Автор молодец, доводи до красоты.


    1. kazmiruk
      07.02.2016 15:37
      -1

      Первая мысль у меня была такая же. А потом я подумал, что ушел с php 5 лет назад, с тех пор многое могло измениться. Назад на этот язык не тянет, но вполне возможно то, что раньше было актуально сейчас уже просто миф?


      1. andymitrich
        07.02.2016 22:54

        На что пересели, если не секрет?)


        1. kazmiruk
          08.02.2016 09:01

          Не секрет, на python


          1. andymitrich
            08.02.2016 11:45

            Занимаетесь вебом?


  1. berez
    07.02.2016 15:00
    +15

    Как было сказано выше, для начала работы нашего демона, в соответствии с правилами работы подобных программ в Linux, его нужно отвязать от терминала, в котором его запустили для этого нужно воспользоваться функцией pcntl_fork(), она создаёт дочернюю копию текущего процесса и возвращает его числовой id в случае успеха. Ну и конечно прикончить родительский процесс.

    Все это вовсе не обязательно.
    Достаточно запустить скрипт в фоновом режиме с использованием nohup:
    % nohup your_script.php &

    В дебиане и убунту есть специальный скрипт для запуска любой программы в виде демона: help.ubuntu.ru/wiki/start-stop-daemon

    А можно вообще не перенаправлять ничего. Просто запускаем скрипт в сессии screen.

    Таким образом, операционная система будет знать что мы способны определять своё поведение и поместит pid нашего процесса в очередь для получения системных сигналов.

    В какую еще очередь на получение системных сигналов? Нет никакой такой «очереди».

    setsid() делает процесс лидером группы и отвязывает его от терминала (т.е. процессу перестают посылать сигнал SIGHUP при закрытии терминала).

    Приступим, для начала необходимо определиться что что класс должен уметь делать.

    Получать и обрабатывать сигналы операционной системы;
    Уметь понимать запущен ли демон или нет;
    Запускать задачу необходимую для демонизации;
    Знать когда нужно остановиться;

    Какой чудесный класс. Авто-, мото-, вело-, фото- гонщик, зритель и пловец.

    Задачи управления демоном (запуск, снятие, проверка пид-файла) практически всегда выполняются внешними скриптами, лежащими в /etc/init.d. И раз уж вы пишете своего собственного демона, то без этих скриптов вам все равно не обойтись.
    Обработка сигналов и собственно выполнение работы — это задача самого демона.

    Здесь мы проверяем все возможные варианты событий, существует ли файл, если да то каким процессом создан, проверяем существует ли такой процесс, если процесс создавший файл не существует, так как процесс завершился неожиданно, пробуем удалить файл

    Отнюдь не все возможные. Например, если в файл записать число 1 (пид процесса init), то ваш демон не запустится никогда.
    Впрочем, это ладно, это стандартная практика.
    А вот выходить без диагностики, если не удалось удалить файл — это как-то очень нехорошо. Собственно, а зачем вы вообще его удаляете? Ведь при несчастливом стечении обстоятельств может получиться так, что вторая копия вашего демона «успеет» запуститься как раз тогда, когда файл удален, но новый файл еще не создан. В результате обе копии решат, что они одни-единственные на свете, обе счастливо запишут свой пид в файл (одна поверх другой), и начнут оттаптывать друг другу пальцы.

    Еще у вас нет никаких блокировок при записи пид-файла. Вы уверены, что file_put_contents() запишет все в файл атомарно, а file_get_contents() его так же атомарно прочтет? Я вот — нет.

    // Собственно создаём демона, соответственно говорим ему куда записывать свой pid…
    $daemon = new Daemon('/tmp/daemon.pid');

    // Закрываем порочные связи со стандартным вводом-выводом…
    fclose(STDIN);
    fclose(STDOUT);
    fclose(STDERR);

    // Перенаправляем ввод-вывод туда куда нам надо или не надо…
    $STDIN = fopen('/dev/null', 'r');
    $STDOUT = fopen('/dev/null', 'wb');
    $STDERR = fopen('/dev/null', 'wb');

    Как я уже говорил, все это практически не нужно. Но если уж так чешутся руки написать свой демонайзер на РНР, то делайте это правильно:

    1. Вместо fclose()/fopen() нужно использовать функцию dup2(), чтобы номера файловых дескрипторов остались гарантированно те же, что и при старте программы. На С это выглядит так:
    int fd = open("/dev/null", O_RDWR, 0);
    if (fd != -1) {
       dup2(fd, STDIN_FILENO);
       dup2(fd, STDOUT_FILENO);
       dup2(fd, STDERR_FILENO);
       if (fd > 2) close(fd);
    }
    


    2. После всех прыжков с файловыми дескрипторами, терминальными сессиями и пид-файлами остался один, но очень важный шаг: сделать chdir("/").
    Смысл этого шага — разблокировать текущий каталог после запуска демона. Скажем, если зайти на подмонтированную флэшку, а потом запустить вашего демона, лежащего, например, в /usr/bin, то флэшку после этого отмонтировать не получится: она останется в качестве текущего рабочего каталога у запущенного демона.

    И да, с запятыми вам надо что-то делать. Примерно в половине фраз отсутствуют.


    1. symbix
      07.02.2016 22:42

      В php dup2, к сожалению, никак не прокинут.

      А вообще, конечно, написание классических демонов в 2015-м году выглядит странно. :)


  1. sledopit
    07.02.2016 16:40

    Но зачем?
    Есть же celery и подобные штуки. php даже умеет с ними работать…


  1. erlyvideo
    07.02.2016 17:05

    > поместит pid нашего процесса в очередь для получения системных сигналов

    А простите, как можно сделать так, что бы пид не получал системные сигналы?


  1. Xu4
    07.02.2016 18:23

    скрипты стартующие с помощью cron каждую секунду (да, да видел и такое)

    У крона минимальный интервал — одна минута, а не одна секунда. Если используются какие-то хаки, вроде запуска 60-ти экземпляров скрипта раз в минуту с инкрементным sleep, то это не значит, что скрипты стартуются с помощью крона каждую секунду.


    1. z0rgoyok
      07.02.2016 18:42
      +1

      можно 60 строчек со sleep написать


      1. Xu4
        08.02.2016 11:01

        Я как раз об этом и писал:

        * * * * * /path/to/command # экземпляр 1
        * * * * * sleep 1 && /path/to/command # экземпляр 2
        * * * * * sleep 2 && /path/to/command # экземпляр 3
        # . . .
        * * * * * sleep 58 && /path/to/command # экземпляр 59
        * * * * * sleep 59 && /path/to/command # экземпляр 60
        


    1. J_o_k_e_R
      08.02.2016 06:35

      Кроны бывают разные. У fcron, например, минимум 20с


  1. malaf
    08.02.2016 01:02
    +3

    Выглядит как пересказ старой статьи на хабре habrahabr.ru/post/40432, даже куски кода взяты те же


  1. Googolplex
    08.02.2016 16:57
    +3

    Сейчас, с современными init-системами, делать самофоркающиеся демоны — это моветон. Гораздо проще написать программу, которая работает как обычно и пишет логи в stdout. После этого написать юнит для systemd или что-то аналогичное — и все «фичи» демона становятся доступны автоматически.


    1. symbix
      08.02.2016 19:45

      Видимо, там просто старый CentOS. Хотя и в этом случае можно установить тот же runit.

      А так — конечно же. Это намного проще и гибче в управлении.
      https://fedoraproject.org/wiki/User:Johannbg/QA/Systemd/Daemon#New-Style_Daemons


  1. DavyJohnes
    08.02.2016 20:57

    Тысяча статей уже написана на эту тему. Да и библиотеки есть давно позволяющее сделать это на более высоком уровне. В гугле вариантов масса. К чему эта статья?


  1. svd71
    09.02.2016 03:23
    +1

    Простите за наивный вопрос, но для какого типа задач требуется запускать подобного демона?