habrahabr

Ускоряем PHP (с ReactPHP)

  • воскресенье, 4 мая 2014 г. в 03:10:37
http://habrahabr.ru/post/220393/

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




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

OPcache

Большинство современных иснталяций используют APC/OPcache и это считается стандартом и максимум для PHP. У этого подхода наименьшее количество недостатков, т.к. это нативное (родное) решение предлагаемое нам командой PHP. Всё бы хорошо, но скорости маловато.

HHVM

HHVM действительно хорош, для популярных Linux дистров уже есть репозитории и остаётся только поставить и настроить, что в целом дело не хитрое. Но это разработка от команды facebook и на данный момент, HHVM сильно ограничивает в выборе расширений, а если у вас вдруг свои патчи для PHP расширений то и вовсе ставит «крест» на безболезненном переходе с PHP на HHVM. Про PHP 5.5 так же можно забыть. Стоит отметить отличную работу facebook команды по увеличению совместимости HHVM с основными инструментами и фреймворками, но это цифра всё таки в районе 97%.

Из приходящих мне в голову вариантов остаются еще сырой HippyVM и фреймворк PhalconPHP. О Phalcon написано много обзоров и думаю повторять их нет смысла. HippyVM в стадии разработки, кстати это альтернатива HHVM от самой же facebook команды, написан на python, что на мой взгляд делает этот проект еще более туманным.

Другие варианты предлагайте в комментариях.

Классическая инсталляция PHP включает в себя установку одного из Nginx, Apache или Lighttpd веб сервера, которые обрабатывают входящие HTTP запросы и перенаправляют динамические к PHP. Существует несколько вариантов подключения PHP к веб серверу:
  • mod_php (apache)
  • f(ast)cgi
  • php-fpm

Все решения по ускорению PHP в целом направлены на ускорение медленного интерпретатора PHP в момент перенаправления запроса от веб сервера к скрипту, что, как видно по тестам быстродействия даёт свой результат. Но в данном решении есть недостаток, как ни крути, но на каждый запрос PHP приложению приходится объявлять классы, создавать экземпляры, подключаться к базам, читать кэш — инициализировать своё окружение. И как бы мы не ускоряли интерпретатор PHP, но на всю инициализацию тратится много ресурсов и такой подход явно далёк от желаемого, особенно для высоконагруженных решений. Почему так происходит? PHP был изначально сконструирован как язык шаблонов и набора инструментов, и не задумывался как самостоятельный веб сервер. К тому же в PHP нет параллельного выполнения или даже асинхронного как у node.js, а все написанные расширения блокирующие.

Но PHP не стоит на месте. У нас появилась своя экосистема с тысячами инструментов которые легко можно установить благодаря Composer. PHP позаимствовал много патернов у таких языков как Java и других, привет команде Symfony & Co. Появились инструменты позволяющие работать PHP асинхронно. На эту тему уже есть статья на хабре, по этому не буду повторятся в описании этого подхода. Скажу только, что асинхронный подход позволяет нам создавать не только чат на websocket, но и запускать полноценный HTTP сервер, а это значит что нам не придётся инициализировать PHP на каждый запрос. Таким образом, не сложно догадаться, что такой подход сведёт на нет затрачиваемое время на старт различных фреймворков и в конечном счёте улучшиться время отклика.

Данное решение, как понятно из заголовка, построено на ReactPHP. Сам React это скорее инструмент для создания, а не готовое решение. Хотя в нём уже есть инструменты для обработки входящих Http соединений, а так же есть различные инструменты, например для работы с websockets или async redis, но нет реализованных привычных для современных фреймворков MVC патерна, роутинга и т.д. Для этих целей мы подключим ReactPHP к уже существующему Symfony2 приложению.

ReactPHP основывается на eventloop и для реализации этой архитектуры предлагает на выбор установить одну из ext-libevent, ext-libev, ext-event. В случае отказа, React работает через stream_select и возможности асинхронности сводятся к минимуму, т.к. по сути всё будет выполняться по очереди без возможности на прерывания процесса. Конечно, можно это опустить, т.к. по сути асинхронность, это и есть череда задач в пределах одного процесса. Но если функция будет использовать не блокирующие вызовы, то eventloop базирующийся на stream_select будет вынужден ждать выполнения этой функции, т.к. не возможности прервать функцию на время выполнения не блокирующего вызова, например к async-redis. Конечно это можно обойти разбиением функционала, но суть проблемы ясна.

Я сторонник нативных решений, и инсталляция pecl расширений туда не очень входит. К тому же установка pecl потребуется на всём парке серверов да и на хостингах будут проблемы. А ведь у PHP есть возможность реализации корутин средствами PHP 5.5. Благодаря замечательной статье от nikic (перевод на хабре), я решил впилить свою реализацию eventloop на основе описанного nikic планировщика задач. Да звучит не просто, и с непривычки действительно требует основательного изменения представления построения приложений на PHP. Но на мой взгляд за такими решениями будущее PHP.

Кстати Syмfony был выбран не случайно. Реализация стека обработки входящих запросов Symfony, нам позволяет с лёгкостью работать не убивая PHP после каждого запроса. А пока я допиливал этот пост, предложения с подобной реализацией уже поступают на канале Symfony. И сами разработчики не скрывают, что подобное решение у них теплится в умах с начала запуска 2 версии.

Но давайте перейдём от слов к делу. Для начала нам потребуется ваш любимый Linux дистрибутив с установленными и настроенными nginx, php-cli 5.5.x, composer и вашим приложением на Symfony. Если у вас нет под рукой Symfony приложения, то можно взять голую инсталляцию с Symfony сайта, на которой и будет приведён пример. Если вам и composer не знаком, то вкратце можно ознакомится в моей статье к Satis.

Создаём новую папку, если проект уже есть то заходим в него:
mkdir fastapp && cd fastapp

Устанавливаем composer:
curl -sS https://getcomposer.org/installer | php

Ставим Symfony2.4.4:
php composer.phar create-project symfony/framework-standard-edition symfdir/ 2.4.4 && mv symfdir/* ./ && rm -fr symfdir

Получаем
ls -l

drwxrwxr-x  6 user user 4.0K Apr 30 11:25 app/
drwxrwxr-x  2 user user 4.0K Apr 30 11:25 bin/
drwxrwxr-x  3 user user 4.0K Mar 14 09:37 src/
drwxrwxr-x 13 user user 4.0K Apr 30 11:25 vendor/
drwxrwxr-x  3 user user 4.0K Apr 30 11:25 web/
-rw-rw-r--  1 user user 2.0K Mar 14 09:37 composer.json
-rw-rw-r--  1 user user  56K Apr 30 11:25 composer.lock
-rwxr-xr-x  1 user user 990K Apr 30 11:23 composer.phar*
-rw-rw-r--  1 user user 1.1K Mar 14 09:37 LICENSE
-rw-rw-r--  1 user user 5.7K Mar 14 09:37 README.md
-rw-rw-r--  1 user user 1.3K Mar 14 09:37 UPGRADE-2.2.md
-rw-rw-r--  1 user user 2.0K Mar 14 09:37 UPGRADE-2.3.md
-rw-rw-r--  1 user user  356 Mar 14 09:37 UPGRADE-2.4.md
-rw-rw-r--  1 user user 8.3K Mar 14 09:37 UPGRADE.md


Добавляем такие строчки в ваш composer.json:
{
    "repositories": [
        { "type": "vcs", "url": "http://github.com/Imunhatep/rephp" },
        { "type": "vcs", "url": "http://github.com/Imunhatep/php-pm" }
    ],
    "minimum-stability": "dev",
    "prefer-stable": true,
    "require": {
        "imunhatep/php-pm": "@dev"
    }
}

Чтоб выглядело примерно так
{
    "name": "symfony/framework-standard-edition",
    "license": "MIT",
    "type": "project",
    "description": "The \"Symfony Standard Edition\" distribution",
    "autoload": {
        "psr-0": { "": "src/" }
    },
    "repositories": [
        { "type": "vcs", "url": "http://github.com/Imunhatep/rephp" },
        { "type": "vcs", "url": "http://github.com/Imunhatep/php-pm" }
    ],
    "minimum-stability": "dev",
    "prefer-stable": true,
    "require": {
        "php": ">=5.3.3",
        "symfony/symfony": "~2.4",
        "doctrine/orm": "~2.2,>=2.2.3",
        "doctrine/doctrine-bundle": "~1.2",
        "twig/extensions": "~1.0",
        "symfony/assetic-bundle": "~2.3",
        "symfony/swiftmailer-bundle": "~2.3",
        "symfony/monolog-bundle": "~2.4",
        "sensio/distribution-bundle": "~2.3",
        "sensio/framework-extra-bundle": "~3.0",
        "sensio/generator-bundle": "~2.3",
        "incenteev/composer-parameter-handler": "~2.0",
        "imunhatep/php-pm": "@dev"
    },
    "scripts": {
        "post-install-cmd": [
            "Incenteev\\ParameterHandler\\ScriptHandler::buildParameters",
            "Sensio\\Bundle\\DistributionBundle\\Composer\\ScriptHandler::buildBootstrap",
            "Sensio\\Bundle\\DistributionBundle\\Composer\\ScriptHandler::clearCache",
            "Sensio\\Bundle\\DistributionBundle\\Composer\\ScriptHandler::installAssets",
            "Sensio\\Bundle\\DistributionBundle\\Composer\\ScriptHandler::installRequirementsFile"
        ],
        "post-update-cmd": [
            "Incenteev\\ParameterHandler\\ScriptHandler::buildParameters",
            "Sensio\\Bundle\\DistributionBundle\\Composer\\ScriptHandler::buildBootstrap",
            "Sensio\\Bundle\\DistributionBundle\\Composer\\ScriptHandler::clearCache",
            "Sensio\\Bundle\\DistributionBundle\\Composer\\ScriptHandler::installAssets",
            "Sensio\\Bundle\\DistributionBundle\\Composer\\ScriptHandler::installRequirementsFile"
        ]
    },
    "config": {
        "bin-dir": "bin"
    },
    "extra": {
        "symfony-app-dir": "app",
        "symfony-web-dir": "web",
        "incenteev-parameters": {
            "file": "app/config/parameters.yml"
        },
        "branch-alias": {
            "dev-master": "2.4-dev"
        }
    }
}


Запускаем обновление пакетов:
php composer.phar update

Получаем
Loading composer repositories with package information
Updating dependencies (including require-dev)         
  - Installing stack/builder (v1.0.1)
    Loading from cache

  - Installing react/promise (v2.0.0)
    Loading from cache

  - Installing guzzle/parser (v3.9.0)
    Loading from cache

  - Installing evenement/evenement (v2.0.0)
    Loading from cache

  - Installing react/react (v0.4.1)
    Loading from cache

  - Installing imunhatep/rephp (dev-master 13adf26)
    Cloning 13adf2697681a5954978ac56fe2c8fdf6a21dc4a

  - Installing imunhatep/php-pm (dev-master 02f44ec)
    Cloning 02f44ecb41ca5b4c81d4bb6087da7a0ed4964656


react/react suggests installing ext-libevent (Allows for use of a more performant event-loop implementation.)
react/react suggests installing ext-libev (Allows for use of a more performant event-loop implementation.)
react/react suggests installing ext-event (Allows for use of a more performant event-loop implementation.)
Writing lock file
Generating autoload files
Updating the "app/config/parameters.yml" file
Clearing the cache for the dev environment with debug true
Installing assets using the hard copy option
Installing assets for Symfony\Bundle\FrameworkBundle into web/bundles/framework
Installing assets for Acme\DemoBundle into web/bundles/acmedemo
Installing assets for Sensio\Bundle\DistributionBundle into web/bundles/sensiodistribution


Подготавливаем Symfony cache:
php app/console cache:warmup --env=dev

И запускаем веб сервер, пока средствами только PHP и в одном экземпляре, тестовый так сказать. Порт можно подобрать по вкусу:
php bin/ppm start --workers 1 --port 8080

Проверяем, что всё работает открыв в любимом браузере localhost:8080. Должна открыться страничка приветствия от Symfony, правда картинки не покажутся и css неподгрузится. Таким образом мы получили PHP веб сервер, обрабатывающий и входящие запросы и не умирающий. Но у нас только 1 процесс, нет обработки статики и нет балансера. Как многие догадались, для этого нам и понадобится nginx.


Настраиваем nginx для проксирования динамических запросов на наш PHP сервер, попутно выполняя роль балансера, а статику отдавать без участия PHP:
upstream backend  {
    server 127.0.0.1:5501;
    server 127.0.0.1:5502;
    server 127.0.0.1:5503;
    server 127.0.0.1:5504;
    server 127.0.0.1:5505;
    server 127.0.0.1:5506;
    server 127.0.0.1:5507;
    server 127.0.0.1:5508;
}

server {
    root /path/to/symfony/web/;
    server_name fastapp.com;

    location / {
                # try to serve file directly, fallback to rewrite
                try_files $uri @rewriteapp;
        }

        location @rewriteapp {
                if (!-f $request_filename) {
                        proxy_pass http://backend;
                        break;
                }
        }
}

При этом server_name (fastapp.com) нужно прописать в /etc/hosts:
127.0.0.1   fastapp.com

Теперь чтоб нам не мучиться с ручным запуском n-количества процессов нашего PHP приложения (представленная nginx конф. настроена на n=8), заходим в папку нашего проекта и выполняем:
cp vendor/imunhatep/rephp/ppm.json ./

Подправляем ./ppm.json файлик:
{
        "bootstrap": "\\PHPPM\\Bootstraps\\Symfony",
        "bridge": "HttpKernel",
        "appenv": "dev",
        "workers": 8,
        "port": 5501
}

Иногда после изменений требуется обновить кэш, возможно это только в моём случае, т.к. при написании статьи производил изменения в коде:
app/console cache:warmup --env=dev

Заново запускаем наш PHP Process Manager:
php bin/ppm start

Получаем в ответ:
8 slaves (5501, 5502, 5503, 5504, 5505, 5506, 5507, 5508) up and ready.

Сначала проверяем в браузере линк localhost:5501, если всё открылось то пробуем открыть fastapp.com. Должно всё открываться, с картинками и css.

Теперь можно жечь при помощи тулзы siege или ab, на выбор:
siege -qb -t 30S -c 128 http://fastapp.com/

Приведу несколько результатов тестирования своего (не helloworld) Symfony приложения, на девелоперской машине с AMD 8core, 8RAM и Fedora20.
Php 5.5.10, через nginx + php-fpm:

siege -qb -t 30S -c 128 http://login.dev/signup

Lifting the server siege...      done.

Transactions:                 983 hits
Availability:              100.00 %
Elapsed time:               29.03 secs
Data transferred:            4.57 MB
Response time:                0.91 secs
Transaction rate:           34.26 trans/sec
Throughput:                0.16 MB/sec
Concurrency:               124.23
Successful transactions:         983
Failed transactions:               0
Longest transaction:            1.81
Shortest transaction:            0.42

Php 5.5.10 с включенным OPcache, через nginx + php-fpm:

siege -qb -t 30S -c 128 http://login.dev/signup
Lifting the server siege...      done.

Transactions:                5298 hits
Availability:              100.00 %
Elapsed time:               29.54 secs
Data transferred:           24.15 MB
Response time:                0.70 secs
Transaction rate:          179.35 trans/sec
Throughput:                0.82 MB/sec
Concurrency:              126.43
Successful transactions:        5298
Failed transactions:               0
Longest transaction:            1.68
Shortest transaction:            0.07

Php 5.5.10 с включенным OPcache, через nginx + ReactPHP + Coroutine eventloop:

siege -qb -t 30S -c 128 http://fastlogin.dev/signup
Lifting the server siege...      done.

Transactions:               30553 hits
Availability:              100.00 %
Elapsed time:               29.85 secs
Data transferred:          157.63 MB
Response time:                0.12 secs
Transaction rate:         1023.55 trans/sec
Throughput:                5.28 MB/sec
Concurrency:              127.43
Successful transactions:       30553
Failed transactions:               0
Longest transaction:            0.76
Shortest transaction:            0.00

Увеличиваем количество параллельных запросов до 256.
Php 5.5.10 с включенным OPcache, через nginx + php-fpm

siege -qb -t 30S -c 256 http://login.dev/signup

siege aborted due to excessive socket failure;

Transactions:                 134 hits
Availability:               10.48 %
Elapsed time:                1.58 secs
Data transferred:            0.78 MB
Response time:                1.21 secs
Transaction rate:           84.81 trans/sec
Throughput:                0.49 MB/sec
Concurrency:              102.93
Successful transactions:         134
Failed transactions:            1145
Longest transaction:            1.56
Shortest transaction:            0.00

К сожалению php-fpm свалился и отказался работать с лимитом в 32 процесса против 256 параллельных запросов.
Пробуем Php5.5.10 + ReactPHP + Coroutine eventloop

siege -qb -t 30S -c 256 http://fastlogin.dev/signup

Lifting the server siege...      done.

Transactions:               29154 hits
Availability:              100.00 %
Elapsed time:               29.16 secs
Data transferred:          150.40 MB
Response time:                0.25 secs
Transaction rate:          999.79 trans/sec
Throughput:                5.16 MB/sec
Concurrency:              252.70
Successful transactions:       29154
Failed transactions:               0
Longest transaction:            3.66
Shortest transaction:            0.00

Заключение.


Идея запускать Symfony приложения через ReactPHP не моя, позаимствовал у Marc из его статьи, за что ему большое спасибо. Кстати он делал свои замеры и даже сравнивал с HHVM. Ниже приведён график из его статьи:

Мой вклад заключается в создании eventloop на основе работы nikic и допиливании менеджера процессов до, в целом, работоспособности, а также нюансов запуска ReactPHP с новым eventloop. Возможно с pecl event lib, будет это всё работать быстрее, не проверял. К сожалению мои текущие проекты не соответствуют требуемому качеству кода, вот наработка и пылится на полках «лаборатории», т.к. такой подход требует кода без ошибок. То есть PHP, по сути, не имеет права падать, а всеядность и динамика PHP ни как этому не способствует. Это можно исправить, дописав PHP PM, чтоб перезапускал упавшие процессы, а так же можно дописать отслеживание изменений в коде и также перезапускать процессы. Но пока не востребовано. Так же на этой базе можно запускать и websocket сервер. Что было в планах, но так там и осталось.

Оставлял такой сервер на все выходные под нагрузкой, утечек памяти не было. Есть одна проблема которую пока нет ни времени ни необходимости искать: по каким то причинам, после нагрузки, остаются не закрытыми 1-2 соединения. На малых нагрузка выявить причину не удаётся, а для больших нужно потратить время чтоб придумать как выявить причину. Пока что, добавил таймер, которые каждые 10 секунд проверяет текущие соединения на валидность (ресурс, не ресурс) и убивает мёртвые.

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