Многопроцессный демон на PHP. Теория и практика. Часть I

15 Mar 2009

Наверное перед многими программистами вставал вопрос о том, как выполнить какую-либо операцию в несколько потоков. Одним из оптимальных вариантов в данном случае было бы создание программных потоков, но, к сожалению, в PHP с потоками работать не получится. Поэтому необходимо искать альтернативные пути решения проблемы многопоточности в PHP.

Решение находится довольно таки быстро в виде системного вызова fork, который создаёт новый дочерний процесс. После вызова fork выполнение программы как-бы разветвляется и код после вызова выполняется два раза. Один раз для родительского процесса и второй раз для дочернего. Отличить эти два процесса можно по результату, который вернул вызов fork. Родительский процесс получает ID только что созданного процесса, а дочерний процесс получает 0. Почему именно так? Дело в том, что стандартной функции, которая бы вернула список всех дочерних процессов для текущего попросту нет, а вот получить ID родительского процесса можно при помощи функции getppid. В случае ошибки fork возвращает -1. В псевдокоде всё вышесказанное можно записать так:

pid = fork();

if (pid == 0) {
    print('we are in child');
    // do work
    exit;
} elseif (pid > 0) {
    print('we are in parent');
    // do work;
    exit;
} else {
    print('fork failed');
    exit;
}

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

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

Для работы с системными вызовами в PHP предназначены два расширения: POSIX и PNCTL. В дальнейшем мы будем использовать функции именно из этих расширений. Если у вас возникнет вопрос по какой-либо функции просто посмотрите её описание в документации.

http://en.wikipedia.org/wiki/Fork_(operating_system)
http://ru.wikipedia.org/wiki/Fork
http://php.net/posix
http://php.net/pcntl

Демонизация родительского процесса

В заголовке этой статьи я не зря упомянул слово «демон». Наше приложение должно уметь отвязываться от терминала и работать в фоновом режиме вплоть до завершения работы операционной системы.

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

$pid = pcntl_fork();

if ($pid == -1) {
    echo "fork error\n";
    exit;
} elseif ($pid > 0) {
    // завершаем работу родительского процесса
    exit;
}

// сейчас мы находимся в дочернем процессе
# ps axj
 PPID   PID  PGID   SID TTY      TPGID STAT   UID   TIME COMMAND
    1  4806  4805  3613 pts/1     4873 S        0   0:00 php daemon.php

После того, как мы оказались в дочернем процессе, необходимо выполнить ряд операций для того, чтобы отвязаться от среды выполнения. Для начала нужно вызвать umask с параметром 0 для сброса маски режима создания файлов. Затем при помощи chdir необходимо установить корневой каталог в качестве рабочего, так как программа может быть запущена с примонтированного раздела, а корневой раздел, как мы знаем, размонтируется в последнюю очередь. И наконец необходимо вызвать setsid для того, чтобы стать лидером в новой сессии и отвязаться от терминала.

umask(0);
chdir('/');

if (posix_setsid() == -1) {
    exit;
}
# ps axj
 PPID   PID  PGID   SID TTY      TPGID STAT   UID   TIME COMMAND
    1  4938  4938  4938 ?           -1 Ss       0   0:00 php daemon.php

Сейчас наш процесс лидер в своей сессии. Сделаем второй вызов fork.

$pid = pcntl_fork();

if ($pid == -1) {
    exit;
} elseif ($pid > 0) {
    exit;
}

// теперь мы находимся в изолированном процессе
# ps axj
 PPID   PID  PGID   SID TTY      TPGID STAT   UID   TIME COMMAND
    1  4955  4954  4954 ?           -1 S        0   0:00 php daemon.php

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

http://php.net/pcntl_fork

Обработка сигналов

После демонизации необходимо решить вопрос взаимодействия с получившимся процессом. Сигналы UNIX – замечательное средство для решения данной задачи. Сигналы представляют из себя ни что иное как программные прерывания. Уже сейчас мы можем использовать сигналы для взаимодействия с процессом, но число вариантов их использования сильно ограничено. Например, мы можем остановить процесс, послав ему, сигнал SIGTERM при помощи утилиты kill.

# ps ax
  PID TTY      STAT   TIME COMMAND
 5107 ?        S      0:00 php daemon.php
# kill -s TERM 5107

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

Хочу заметить, что многие сигналы могут быть проигнорированы или трактованы приложением по своему. Например, получив сигнал SIGTERM мы можем не завершать работу приложения. В случае если приложение игнорирует любые сигналы, единственным способом остановить его является отправка сигнала SIGKILL или SIGSTOP, которые приводят к немедленному завершению процесса.

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

pcntl_signal(SIGTERM, 'sigterm_handler');

function sigterm_handler($signo) {
    // удаляем временные файлы
    delete_all_tmp_files();
    // завершаем работу приложения
    exit;
}

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

pcntl_signal(SIGTERM, 'sig_handler');
pcntl_signal(SIGQUIT, 'sig_handler');
pcntl_signal(SIGHUP,  'sig_handler');

function sig_handler($signo) {
    switch ($signo) {
        case SITERM:
        case SIGQUIT:
            // удаляем временные файлы
            delete_all_tmp_files();
            // завершаем работу приложения
            exit;
        break;
        case SIGHUP:
            reload_configuration_file();
        break;
    }
}

Так же мы можем проигнорировать сигнал.

pcntl_signal(SIGHUP, SIG_IGN);

Или установить обработчик сигнала по умолчанию, если больше нет необходимости в самостоятельной обработке сигнала.

pcntl_signal(SIGHUP, SIG_DFL);
Для того, чтобы работать с сигналами в PHP необходимо использовать оператор declare, чтобы указать то место, где будут обрабатываться сигналы.
declare(ticks=1);

function sig_handler($signo) {
    // ...
}

http://ru.wikipedia.org/wiki/Сигналы_(UNIX)
http://en.wikipedia.org/wiki/Unix_signals
http://php.net/pcntl_signal

Рабочий цикл

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

define('WORK_CYCLE_DELAY', 100000);

while (true) {
    // делаем полезную работу
    // и ждём следующую итерацию
    usleep(WORK_CYCLE_DELAY);
}

Для того, чтобы не потреблять много процессорного времени, в цикле ставим задержу при помощи функции usleep.

Создание рабочих процессов.

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

define('WORKER_PROCESSES', 3);

// ...

$worker_processes = array();

for ($i = 0; $i < WORKER_PROCESSES; $i++) {
    $pid = pcntl_fork();

    if ($pid == -1) {
        // не удалось создать рабочий процесс
        continue;
    } elseif ($pid > 0) {
        // записываем ID только что созданного процесса
        $worker_processes[] = $pid;
        // переходим к созданию следующего процесса
        continue;
    }

    // сейчас мы в рабочем процессе и можем выполнять работу
    exit;
}
# ps ajx
 PPID   PID  PGID   SID TTY      TPGID STAT   UID   TIME COMMAND
    1  8921  8920  8920 ?           -1 S     1003   0:00 php daemon.php
 8921  8922  8920  8920 ?           -1 S     1003   0:00 php daemon.php
 8921  8923  8920  8920 ?           -1 S     1003   0:00 php daemon.php
 8921  8924  8920  8920 ?           -1 S     1003   0:00 php daemon.php

Как мы помним, для родительского процесса вызов fork возвращает ID только что созданного дочернего процесса. Мы будем использовать возвращаемый ID для того, чтобы составить список дочерних процессов, который позже нам пригодится для управления этими процессами из родительского.

Дополнительные сведения об обработке сигналов

Если дочерний процесс завершит свою работу раньше чем родительский, то он станет зомби. Процесс-зомби — дочерний процесс, который уже завершил свою работу, но ещё не пропал из списка процессов.

# ps ajx
 PPID   PID  PGID   SID TTY      TPGID STAT   UID   TIME COMMAND
    1  5265  5264  5264 ?           -1 S        0   0:00 php daemon.php
 5265  5266  5264  5264 ?           -1 Z        0   0:00 [php] <defunct>
 5265  5267  5264  5264 ?           -1 S        0   0:00 php daemon.php
 5265  5268  5264  5264 ?           -1 S        0   0:00 php daemon.php

Отличить такой процесс можно по статусу Z и подстроке <defunct> в списке процессов. Как видно он не теряет свой идентификатор.

Почему дочерний процесс просто так не может удалиться из списка процессов? Дело в том, что операционная система даёт возможность родительскому процессу считать код возврата его завершившегося дочернего процесса. Как только дочерний процесс завершается, операционная система отправляет родительскому процессу сигнал SIGCHLD. В ответ на этот сигнал, родительский процесс должен считать код возврата при помощи вызова wait. Если родительский процесс игнорирует этот сигнал, или не считает код возврата, то процесс-зомби остаётся в списке процессов вплоть до завершения родительского процесса.

function sig_handler($signo) {
    if ($signo == SIGCHLD) {
        $worker_processes =& $GLOBALS['worker_processes'];

        while (($pid = pcntl_waitpid(-1, $status, WNOHANG)) > 0) {
            if ($k = array_search($pid, $worker_processes)) {
            unset($worker_processes[$k]);
            }
        }
    }
}

Вызов функции pcntl_waitpid не случайно помещён в цикл. Если работу одновременно завершат несколько дочерних процессов, то это вовсе не означает, что сигнал SIGCHLD будет отправлен для каждого из них. Операционная система может отправить один сигнал на группу завершившихся процессов, поэтому логичным будет считать код возврата каждого из них.

При вызове fork дочерний процесс наследует от родительского весь контекст обработки сигналов. Это значит, что для родительского и дочернего процессов будут выполнятся одни и те же обработчики сигналов. Нас такая ситуация может не устраивать, поэтому мы должны как-то различать процессы в обработчике. Самый простой способ — записать тип процесса в глобальную переменную. Сделать мы это должны сразу после создания процесса.

define('PROCESS_TYPE_UNKNOWN', 1);
define('PROCESS_TYPE_MASTER',  2);
define('PROCESS_TYPE_WORKER',  3);

$process_type = PROCESS_TYPE_UNKNOWN;

// ...

$pid = pcntl_fork();

if ($pid == -1) {
    exit;
} elseif ($pid > 0) {
    exit;
}

$process_type = PROCESS_TYPE_MASTER;

// ...

for ($i = 0; $i < WORKER_PROCESSES; $i++) {
    $pid = pcntl_fork();

    if ($pid == -1) {
        continue;
    } elseif ($pid > 0) {
        $worker_processes[] = $pid;

        continue;
    }

    $process_type = PROCESS_TYPE_WORKER;

//...

После этого, мы можем определить тип процесса, сравнив переменную $process_type с одной из объявленных констант.

function sig_handler($signo) {
    switch ($GLOBALS['process_type']) {
        case PROCESS_TYPE_MASTER:
            switch ($signo) {
                case SIGTERM:
                case SIGQUIT:
                    delete_all_tmp_files();
                    exit;
                break;
                case SIGUSR1:
                    // выполняем какое-нибудь действие
                break;
            }
        break;
        case PROCESS_TYPE_WORKER:
            switch ($signo) {
                case SIGTERM:
                    exit;
                break;
            }
        break;
    }
}

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

$execute_some_procedure = 0;

function sig_handler($signo) {
    if ($signo == SIGUSR1) {
        $GLOBALS['execute_some_procedure'] = 1;
    }
}

// ...

while (true) {
    if ($execute_procedure == 1) {
        execute_some_procedure = 0;
        some_procedure();

        continue;
    }

    usleep(WORK_CYCLE_DELAY);
}

http://www.opennet.ru/base/dev/unix_signals.txt.html

Управление дочерними процессами

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

foreach ($worker_processes as $k => $pid) {
    unset($worker_processes[$k]);
    posix_kill(SIGTERM, $pid);
}

Дочерний процесс может обработать этот сигнал следующим образом.

function sig_handler($signo) {
    if ($signo == SIGTERM) {
        exit;
    }
}

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

$stop_graceful = 0;

function sig_handler($signo) {
    if ($signo == SIGQUIT) {
        $GLOBALS['stop_graceful'] = 1;
    }
}

// ...
// мы в дочернем процессе

while (true) {
    // выполняем полезную работу

    if ($stop_graceful == 1) {
        exit;
    }

    usleep(WORK_CYCLE_DELAY);
}

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

http://php.net/posix_kill

Поддержание уникальности процесса. PID файл

У нас нет необходимости запускать несколько демонов, поэтому мы будем отслеживать ситуацию повторного запуска. Для этого применяется, так называемый pid файл, который хранит ID процесса демона. Эти файлы принято называть по имени демона с расширением .pid и размещать в директории /var/run. Перед демонизацией приложение проверяет наличие файла и связанного с ним процесса. Если приложение уже запущено, то повторого запуска произведено не будет.

define(PIDFILE_NAME, 'myfirstd.pid');

if (is_readable(PIDFILE_NAME)) {
    $pid = (int)file_get_contents(PIDFILE_NAME);

    if ($pid > 0 && posix_kill($pid, 0)) {
        echo "Daemon already running\n";
        exit;
    }

    if (!unlink(PIDFILE_NAME)) {
        echo "Pid delete failed\n";
        exit;
    }
}

Если процесс завершается аварийно, то приложение оставляет за собой pid файл, поэтому мы дополнительно проверяем наличие процесса.

Сразу после демонизации приложение должно записать ID своего процесса в pid файл.

$pid = posix_getpid();

if (!file_put_contents(PIDFILE_NAME, $pid)) {
    exit;
}

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

Журналирование ошибок

После того, как мы отвязали приложение от терминала у него пропала возможность выводить туда сообщения. Для того, чтобы не усложнять наше приложение всю работу по журналированию мы возложим на syslog.

Файлы журналов хранятся в каталоге /var/log. Мы должны открыть выбранный нами журнал для записи.

if (!openlog('myfirstd', LOG_PID, LOG_USER)) {
    exit;
}

Теперь мы можем записывать сообщения.

if (posix_setsid() == -1) {
    syslog(LOG_ERR, 'setsid failed');
    exit;
}
# ps axj
 PPID   PID  PGID   SID TTY      TPGID STAT   UID   TIME COMMAND
    1  7377  7376  7376 ?           -1 S        0   0:00 php daemon.php
# cat /var/log/syslog | grep myfirstd
Mar  6 18:43:38 localhost myfirstd[7376]: setsid failed

http://www.opennet.ru/cgi-bin/opennet/man.cgi?topic=syslogd
http://ru.php.net/openlog
http://ru.php.net/syslog

Заключение

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

нет тэгов
16 Mar 2009 Elkaz
Полезная статья. Спасибо.
01 Apr 2009 Горбунов
Молодец, Серега. Хороших и подробных статей на русском мало.
07 Oct 2009 GiBSON
Статья хороша но есть немного опечаток
10 Nov 2009 Александр
Респект! То что искал!!! Думал уже за си браться ...
01 Dec 2009 Александр
Замечательная статья. А не пора бы уже продолжение написать? :)
22 Nov 2010 Alex
Ждем продолжения)
07 Apr 2011 Kein
Статья супер, во время мне помогла, спасибо большое)
03 Aug 2011 Nati
Большое спасибо за статью!
Узнал много нового и полезного =)
30 Aug 2011 omlk
Жаль продовження нема :(
30 Sep 2011 nmaxeve
pochemu to PS AJX ne pokazivaet v liste moego demona...
26 Nov 2011 dick
Аффтар пеши исчо!

Имя:

Сообщение: