phpDaemon — фреймворк асинхронных приложений
Сегодня речь пойдет о phpDaemon — асинхронном модульном демоне-фреймворке, который берёт на себя обработку I/O (libevent) и другие низкоуровневые задачи, присущие демонам. С его помощью легко писать правильные сетевые приложения с блэкджеком и шлюхами.
Из коробки идут сервера FastCGI, HTTP, CGI, FlashPolicy, Telnet, WebSocket (!) — да-да тот самый волшебный пендаль новый протокол от Google. И клиенты mysql, memcached, mongodb… И многое другое, полный список под катом. Работать с сетью действительно просто. Программист средней руки может написать, к примеру, IRC-бота за считанные часы.
В качестве наглядного примера я реализовал вот этот чат на phpDaemon + WebSocket + MongoDB + jQuery. Он наглядно демонстрирует преимущества этой технологии: доставка сообщений мгновенна, накладные расходы при обмене данными минимальны, высока производительность, приложение масштабируется горизонтально. Исходники этого чата (в данный момент 17 кб). Прошу заметить, чат тестировался и работает в Chrome, FF, IE6+, Iron, Safari.
Где это лучше применить?
Область применения phpDaemon очень широка как в Веб-разработке, так и за её пределами. С его помощью хорошо делать real-time многопользовательские игры (например на Flash), сервисы с мгновенным взаимодействием, чаты, IM-гейты… Также хорошо использовать необходимые в хозяйстве полезности, такие как сервер flashpolicy.
Самое первое реальное применение проекта в production — мгновенная доставка личных сообщений: на страницах сайта был невидимый swf-файл (Flash) в несколько килобайт, он подключался к серверу и ждал команд. Как только пользователю приходило сообщения, Flash посылал команду странице (Javascript), который в свою очередь сразу же отображал всплывающую иконку и «пиликал». Мы с коллегами были поражены скоростью реакции, звук от соседнего компьютера был слышен еще до того как отправитель успевал оторвать палец от левой кнопки мыши.
А совсем недавно по заказу был реализован сервер предоставляющий API к Asterisk’у (для IP-телефонии), прекрасно держит нагрузку.
Архитектура и возможности
Приложения в phpDaemon содержат лишь логику обработки, а все низкоуровневые вызовы происходят автоматически. Проект написан исключительно на PHP, и вы, вероятно уже задались вопросом производительности. I/O происходит через libevent, библиотеку хорошо зарекомендовавшую себя, которая используется в memcached и в других известных проектах. При этом, скорость выполнения инструкций в PHP весьма велика, он превосходит в этом многие другие языки своей группы. В привычных всем синхронных скриптах львиная доля времени уходит на запуск среды и на блокировку при вводе-выводе, например при запросе к базе данных обычный скрипт не продолжит выполнение пока ответ не будет получен. В phpDaemon таких блокировок нет: отослали запрос, повесили если нужно callback-функцию на ответ, и пошли по своим делам.
phpDaemon — это 1 мастер-процесс и множество рабочих процессов, каждый из которых представляет собой среду выполнения приложений. Приложение является классом-наследником AppInstance, который описывает его реакцию на различные события. При запуске рабочих процессов, они создают экземпляры классов-приложений и выполняют метод init(). В методе init() приложение может забиндить сокет, куда-то подключиться, или открыть локальный дескриптор (при этом одно не исключает другого). Приложение также может взаимодействовать с другими приложениями в рабочем процессе, объявляя в них необходимые параметры: например, прописать роут в WebSocketServer.
После того как оно забиндило сокет, при поступлении подходящих соединений будет вызвано событие onAccepted(connId,addr). Пример этого метода из WebSocketServer:
public function onAccepted($connId,$addr)
$ this ->sessions[$connId] = new WebSocketSession($connId,$ this );
$ this ->sessions[$connId]->clientAddr = $addr;
>
* This source code was highlighted with Source Code Highlighter .
Сервер отдающий политику crossdomain по 843 порту весит всего 1,67 кб — FlashPolicy.php. Между тем, его явное преимущество перед множеством аналогов очевидно: его нельзя положить открыв десяток-другой телнет-сессий. Многие популярные реализации flashpolicyd DoS-ятся открытием даже одной сессии, которая ничего не шлет, поскольку в них идёт синхронная обработка, и процесс ждет у моря погоды.
Для обработки HTTP-запросов (в том числе по FastCGI) существует отдельная сущность — классы-наследники Request. Эта сущность имеет входные параметры (get,post,cookie,server. ) и состояния — run/sleep/dead. Запросы висят в очереди, и диспетчер вызывает их по порядку. Если запросу не нужно прерываться, он может сделать всю работу и вернуть код завершения, чтобы диспетчер удалил его из очереди. Если запрос производит операции, требующие ожидания (например делает запрос к MongoDB), то ему необходимо сделать $this->sleep(30) чтобы заснуть максимум на 30 на секунд. А в callback-функции к запросу MongoDB достаточно указать $request->wakeup(), чтобы немедленно прогнать сон. Тогда диспетчер обратится к нему незамедлительно. Запрос сможет продолжить своё выполнение, имея ответ от MongoDB. Если же ответ по каким-то причинам не получен, запрос может вывести «We’re sorry, try again shortly later». Для полного завершения запроса в методе run() вызывается либо return 1, либо $this->terminate().
После того как заголовки запроса приняты приложения-сервера (коробочные — FastCGI, HTTP) обращаются к appResolver’у, который определяет к кому приложению поступает запрос и вызывает у приложения метод beginRequest. А он, в свою очередь, создает и возвращает экземпляр класса-наследника Request. Затем объект запроса кладется в очередь (queue). Приложение может по желанию добавлять в очередь и собственные «запросы» посредством метода pushRequest, это необходимо чтобы вызывать определенный код через задаваемые промежутки времени (например, это делается в MongoNode для опроса курсора).
Запросами в полной мере поддерживаются POST-данные (и multipart), Upload’ы, и прочее (страничка-пример), более того — больше нет необходимости вручную обрабатывать UCS-2 кодировку в запросах (%uFFFF) — это происходит автоматически.
Поддерживаются X-Sendfile (запись ответа на запроса веб-сервера в файл) и Request-Body-File (чтение body-часть запроса из файла). Можно начать выполнение запроса до того как тело полностью принято, и можно программировать обработку upload’а (например, кидать сразу в memcached, а не во временный файл на диске).
Предвижу просьбы дать конкретные цифры показывающие выигрыш в производительности, но некорректно сравнивать синхронные и асинхронные фреймворки. Понятно что последние быстрее. Однако, разница будет напрямую зависеть от приложения, а именно — от количества и времени блокировок в синхронном варианте. Добавлю лишь субъективный бенчмарк странички-примера… Requests per second: 4784.80 [#/sec] (mean).
В данный момент используется последняя стабильная версия libevent (1.4.10-stable), скоро будет выпущена libevent 2.0, это даст возможность рабочим процессам принимать соединения через epoll_wait и принесет дополнительный выигрыш в производительности перед текущим вариантом. Как только так сразу.
Управляемость, администрирование, конфигурирование
# phpd
usage: phpd (start|(hard)stop|update|reload|(hard)restart|fullstatus|status|configtest|help)…
# phpd fullstatus
[STATUS] phpDaemon 0.2 is running (/var/run/phpdaemon.pid).
State of workers:
Total: 1
Idle: 1
Busy: 0
Shutdown: 1
Pre-init: 0
Wait-init: 0
Init: 0
Рабочими процессами поддерживается команды suid, sgid, chroot. Они задаются на уровне конфигурации. Встроенный динамический MPM (Multi-Process Manager) определяет загруженность рабочих процессов и запускает новые в рамках настроек. Используя API, алгоритм MPM можно переопределить в конфигурационном файле. При разработке проекта делается упор как на расширяемость и заменяемость компонентов, так и на простоту использования.
Искаропки
- FastCGI — позволяет подключаться к приложениям по протоколу FastCGI.
- HTTP — позволяет подключаться к приложениям по HTTP.
- CGI — позволяет пускать обычные CGI-приложения по FastCGI, HTTP и любым другим транспортам.
- Flashpolicy — раздает политику crossdomain для Flash по 843 порту.
- WebSocketServer — обслуживает WebSocket-сеансы.
- MongoClient — драйвер MongoDB.
- MySQLClient — драйвер MySQL (через него также можно подключаться к SphinxQL).
- MySQLProxy — проксирующий MySQL-сервер.
- MongoNode — реализация slave-ноды MongoDB, позволяет вешать события на изменения объектов в базе.
- MemcacheClient — драйвер Memcache.
- LockServer — распределенный сервис блокировок.
- LockClient — клиент для собственного сервиса блокировок.
- TelnetHoneypot — простейший сервер telnet.
- RTEPServer/RTEPClient — реализация Real-Time Events Protocol, позволяющая клиентам подписываться на события и анонсировать их.
- BitTorrentTracker — трекер BitTorrent с использованием MongoDB.
- DebugConsole — сервер интерактивной отладочной консоли a-la telnet, который позволяет выполнять код в запущенном процессе.
Будем рады вашим модулям в дистрибутиве!
Баранов в стоило, холодильник в дом
Лицензия — LGPL. Проект относительно свежий, в чем-то сыроватый, но стабильный, и используется в production. Разработка ведется активно. В случае отсутствия форс-мажора, фиксы выходят от нескольких минут до двух суток после репорта.
P.S. Если вам подобная архитектура импонирует — возможна поддержка, даже в принципе написание модулей на заказ.
Основы
Если у тебя возникнет вопрос по поводу какой-то незнакомой функции — не расстраивайся! Они все задокументированы в PHP Manual. Вряд ли у меня получится рассказать о них подробнее и интереснее.
Форкинг (плодим процессы)
Как из одного процесса сделать два? Программистам под Windows (в том числе и мне) больше знакома система, когда мы пишем функцию, которая будет main() для дочернего потока. В *nix все не так, потому я немного расскажу об этой системе многопроцессовости. *nixоиды могут смело пропустить эту часть, если они и так все знают.
Итак. Есть такая функия pcntl_fork . Как ни странно, аргументов она не берет. Что же делать?
После pcntl_fork у скрипта начинается шизофрения: код вроде бы один и тот же, но выполняется двумя параллельными процессами. Впрочем, если просто вставить в скрипт pcntl_fork , ничего наглядного ты не увидишь, разве что конфликты доступа к ресурсам.
Фишка в том, что pcntl_fork возвращает 0 дочернему процессу и PID дочернего процесса — родительскому. Вот обычный паттерн использования pcntl_fork :
$pid = pcntl_fork(); if ($pid == -1) < //ошибка >elseif ($pid) < //сюда попадет родительский процесс >else < //а сюда - дочерний процесс >//а сюда попадут оба процесса
Кстати, pcntl_fork работает только в CGI и CLI-режимах. Из-под апача — нельзя. Логично.
Демонизация
Чтобы демонизировать скрипт, нужно отвязать его от консоли и пустить в бесконечный цикл. Давай посмотрим, как это делается.
// создаем дочерний процесс $child_pid = pcntl_fork(); if( $child_pid ) < // выходим из родительского, привязанного к консоли, процесса exit; >// делаем основным процессом дочерний. // После этого он тоже может плодить детей. // Суровая жизнь у этих процессов. posix_setsid();
После таких действий мы остаемся с демоном — программой без консоли. Чтобы она не завершила свое выполнение немедленно, пускаем ее в бесконечный цикл (ну, почти):
Дочерние процессы
На данный момент наш демон однопроцессовый. По ряду очевидных причин этого может быть недостаточно. Рассмотрим создание дочерних процессов.
$child_processes = array(); while (!$stop_server) < if (!$stop_server and (count($child_processes) < MAX_CHILD_PROCESSES)) < //TODO: получаем задачу //плодим дочерний процесс $pid = pcntl_fork(); if ($pid == -1) < //TODO: ошибка - не смогли создать процесс >elseif ($pid) < //процесс создан $child_processes[$pid] = true; >else < $pid = getmypid(); //TODO: дочерний процесс - тут рабочая нагрузка exit; >> else < //чтоб не гонять цикл вхолостую sleep(SOME_DELAY); >//проверяем, умер ли один из детей while ($signaled_pid = pcntl_waitpid(-1, $status, WNOHANG)) < if ($signaled_pid == -1) < //детей не осталось $child_processes = array(); break; >else < unset($child_processes[$signaled_pid]); >> >
Обработка сигналов
Следующая по важности задача — обеспечение обработки сигналов. Сейчас наш демон ничего не знает о внешнем мире, и убить его можно только завершением процесса через kill -SIGKILL . Это плохо. Это очень плохо — SIGKILL прервет процессы на середине. Кроме того, ему никак нельзя передать информацию.
Есть куча интересных сигналов, которые можно обрабатывать, но мы остановимся на SIGTERM — сигнале корретного завершения работы.
//Без этой директивы PHP не будет перехватывать сигналы declare(ticks=1); //Обработчик function sigHandler($signo) < global $stop_server; switch($signo) < case SIGTERM: < $stop_server = true; break; >default: < //все остальные сигналы >> > //регистрируем обработчик pcntl_signal(SIGTERM, "sig_handler");
Вот и все. Мы перехватываем сигнал — ставим флаг в скрипте — используем этот флаг, чтоб не запускать новые потоки и завершить основной цикл.
Поддержание уникальности демона
И последний штрих. Нужно, чтобы демон не запускался два раза. Обычно для этих целей используются т.н. .pid-файлы — файл, в котором записан pid данного конкретного демона, если он запущен.
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; > if (isDaemonActive(‘/tmp/my_pid_file.pid’))
А после демонизации — нужно записать в pid-файл текущий PID демона.
file_put_contents('/tmp/my_pid_file.pid', getmypid());
Вот и все, что нужно знать для написания демонов на PHP. Я не рассказывал об общем доступе к ресурсам, потому что эта проблема шире, чем написание демонов.