Атомный реактор в каждый сайт

в 7:33, , рубрики: async, cleverstyle, cmf, cms, HHVM, php, React, ReactPHP, Веб-разработка, метки:

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

Атомный реактор в каждый сайт - 1

Атомный реактор — это проект ReactPHP, в описании указано «Nuclear Reactor written in PHP». На знакомство с ним меня подтолкнула вот эта статья (картинка выше оттуда). Я перечитывал её несколько раз на протяжении года, но никак не получалось добраться до имплементации на практике, хотя рост производительности более чем на порядок в перспективе очень радовал.

Исходное состояние

В качестве подопытной системы выступает CleverStyle CMS, движок кэшировния APCu, версия в разработке, то есть установлены все возможные компоненты, в тестах открывается страница модуля Static pages.
В качестве тестовой железки выступает рабочий ноутбук с Core i7 4900MQ (4 ядра, 8 потоков), ОС Ubuntu 15.04 x64, дисковая подсистема состоит из двух SATA3 SSD в RAID0 (soft, btrfs, пока не лучший вариант для БД, оказалось достаточно узким местом в тестах, но есть что есть), перед каждым тестом запускается sudo sync, при каждом запросе производится 2-4 запроса в БД (создание сессии посетителя, не кэшируются на уровне БД), у Nginx 16 воркеров.
Условия не лабораторные, но с чем-то нужно работать)
Тестировать производительность будем простым Apache Benchmark.

Сначала PHP-FPM (PHP 5.5, 16 воркеров, статически):

Скрытый текст

nazar-pc@nazar-pc ~> ab -n5000 -c128 cscms.org:8080/uk
This is ApacheBench, Version 2.3 <$Revision: 1604373 $>
Copyright 1996 Adam Twiss, Zeus Technology Ltd, www.zeustech.net/
Licensed to The Apache Software Foundation, www.apache.org/

Benchmarking cscms.org (be patient)
Completed 500 requests
Completed 1000 requests
Completed 1500 requests
Completed 2000 requests
Completed 2500 requests
Completed 3000 requests
Completed 3500 requests
Completed 4000 requests
Completed 4500 requests
Completed 5000 requests
Finished 5000 requests

Server Software: nginx/1.6.2
Server Hostname: cscms.org
Server Port: 8080

Document Path: /uk
Document Length: 99320 bytes

Concurrency Level: 128
Time taken for tests: 22.280 seconds
Complete requests: 5000
Failed requests: 4239
(Connect: 0, Receive: 0, Length: 4239, Exceptions: 0)
Total transferred: 498328949 bytes
HTML transferred: 496603949 bytes
Requests per second: 224.41 [#/sec] (mean)
Time per request: 570.373 [ms] (mean)
Time per request: 4.456 [ms] (mean, across all concurrent requests)
Transfer rate: 21842.25 [Kbytes/sec] received

Connection Times (ms)
min mean[±sd] median max
Connect: 0 0 0.5 0 3
Processing: 26 563 101.6 541 880
Waiting: 24 559 101.3 537 872
Total: 30 564 101.4 541 881

Percentage of the requests served within a certain time (ms)
50% 541
66% 559
75% 572
80% 584
90% 759
95% 795
98% 817
99% 829
100% 881 (longest request)

Конкурентность 128, поскольку при 256 PHP-FPM просто падает.

Теперь HHVM, для начала прогреем HHVM с помощью 50 000 запросов (почему), потом выполним тест:

Скрытый текст

nazar-pc@nazar-pc ~> ab -n5000 -c256 cscms.org:8000/uk
This is ApacheBench, Version 2.3 <$Revision: 1604373 $>
Copyright 1996 Adam Twiss, Zeus Technology Ltd, www.zeustech.net/
Licensed to The Apache Software Foundation, www.apache.org/

Benchmarking cscms.org (be patient)
Completed 500 requests
Completed 1000 requests
Completed 1500 requests
Completed 2000 requests
Completed 2500 requests
Completed 3000 requests
Completed 3500 requests
Completed 4000 requests
Completed 4500 requests
Completed 5000 requests
Finished 5000 requests

Server Software: nginx/1.6.2
Server Hostname: cscms.org
Server Port: 8000

Document Path: /uk
Document Length: 99309 bytes

Concurrency Level: 256
Time taken for tests: 20.418 seconds
Complete requests: 5000
Failed requests: 962
(Connect: 0, Receive: 0, Length: 962, Exceptions: 0)
Total transferred: 498398875 bytes
HTML transferred: 496543875 bytes
Requests per second: 244.88 [#/sec] (mean)
Time per request: 1045.408 [ms] (mean)
Time per request: 4.084 [ms] (mean, across all concurrent requests)
Transfer rate: 23837.54 [Kbytes/sec] received

Connection Times (ms)
min mean[±sd] median max
Connect: 0 0 1.5 0 8
Processing: 505 1019 102.6 1040 1582
Waiting: 505 1017 102.9 1039 1579
Total: 513 1019 102.5 1040 1586

Percentage of the requests served within a certain time (ms)
50% 1040
66% 1068
75% 1080
80% 1087
90% 1108
95% 1126
98% 1179
99% 1397
100% 1586 (longest request)

Получили 245 запросов в секунду, с этим и будем работать.

Первые шаги

Хочется чтобы код не зависел от того, запускается ли он из-под HTTP сервера написанного на PHP, или в более привычном режиме.
Для этого были утилизированы headers_list()/header_remove() и http_response_code(), суперглобальные $_GET, $_POST, $_REQUEST, $_COOKIE, $_SERVER наполнялись вручную.
Системные классы разрушались после каждого запроса и создавались при новом.
В целом работало, но были нюансы:

  • В случае использования асинхонных операций где больше одного запроса будут выполняться одновременно всё накроется медным тазом
  • Создание всех ситемных объектов всё ещё создавало существенные накладные расходы, хотя это и работало быстрее чем полный перезапуск скрипта
  • Не запускалось из-под PHP-CLI, для отправки заголовков нужен PHP-CGI, у которого течет память (по неведомой причине) при долгоиграющем процессе
  • Если кто-то решил вызвать exit()/die() — всё умирает

Оптимизации, поддержка асинхронности

Во-первых системные объекты были разделены на две группы — первая, запросы которые зависят от пользователя и конкретного запроса, вторая — полностью независимые.
Независимые объекты перестали разрушаться после каждого запроса что дало существенный прирост скорости.
Объект, который принимает запрос от ReactPHP и формирует ответ получил дополнительное поле __request_id. При получении системного объекта, который зависит от конкретного запроса с помощью debug_backtrace() достается этот __request_id, что позволяет разделить эти объекты для каждого отдельного запроса даже при асинхронности.
Так же были выделены отдельно системные функции, которые работают с глобальным состоянием, для HTTP сервера подключались модифицированные их версии, которые учитывают __request_id. Были добавлены функции _header() вместо header() (для работы заголовков под PHP-CLI), _http_response_code() вместо http_response_code(), уже существующие _getcookie() и _setcookie() были модифицированы, последняя под капотом вручную формирует заголовки для изменения cookie и отправляет их в _header().
Суперглобальные переменные заменяются массиво-подобными объектами, и при доступе к элементам такого странного массива мы получим данные, соответствующие конкретному запросу — тут совместимость с обычным кодом высока, главное не перезаписывать суперглобальные переменные, и иметь ввиду что там может быть не совсем массив (например, если использовать с array_merge()).
В качестве ещё одного компромиссного решения в систему был добавлен ExitException, которым заменяются вызовы exit()/die() (в том числе модифицируются сторонние библиотеки при надобности, кроме ситуаций когда реально нужно завершение всего скрипта), это позволяет перехватить выход на самом верху, и избежать завершения выполнения скрипта.

Тестируем результат на пуле из 16 запущенных Http серверов (интерпретатор HHVM), Nginx балансирует запросы (прогрев 50 000 запросов на пул):

Скрытый текст

nazar-pc@nazar-pc ~> ab -n5000 -c256 cscms.org:9990/uk
This is ApacheBench, Version 2.3 <$Revision: 1604373 $>
Copyright 1996 Adam Twiss, Zeus Technology Ltd, www.zeustech.net/
Licensed to The Apache Software Foundation, www.apache.org/

Benchmarking cscms.org (be patient)
Completed 500 requests
Completed 1000 requests
Completed 1500 requests
Completed 2000 requests
Completed 2500 requests
Completed 3000 requests
Completed 3500 requests
Completed 4000 requests
Completed 4500 requests
Completed 5000 requests
Finished 5000 requests

Server Software: nginx/1.6.2
Server Hostname: cscms.org
Server Port: 9990

Document Path: /uk
Document Length: 99323 bytes

Concurrency Level: 256
Time taken for tests: 16.092 seconds
Complete requests: 5000
Failed requests: 1646
(Connect: 0, Receive: 0, Length: 1646, Exceptions: 0)
Total transferred: 498418546 bytes
HTML transferred: 496643546 bytes
Requests per second: 310.71 [#/sec] (mean)
Time per request: 823.928 [ms] (mean)
Time per request: 3.218 [ms] (mean, across all concurrent requests)
Transfer rate: 30246.49 [Kbytes/sec] received

Connection Times (ms)
min mean[±sd] median max
Connect: 0 0 0.9 0 6
Processing: 100 804 308.3 750 2287
Waiting: 79 804 308.2 750 2285
Total: 106 804 308.1 750 2287

Percentage of the requests served within a certain time (ms)
50% 750
66% 841
75% 942
80% 990
90% 1180
95% 1381
98% 1720
99% 1935
100% 2287 (longest request)

Уже неплохо, 310 запросов в секунду это в 1,26 раза больше чем HHVM в обычном режиме.

Оптимизируем дальше

Поскольку изначально код не писался асинхронным — один запрос перед другим не выскочит, поэтому можно добавить обычный, не асинхронный режим, и допустить что запросы будут исполняться строго по очереди.
В таком случае мы можем обойтись обычными массивами в суперглобальных переменных, не нужно делать debug_backtrace() при создании системных объектов, а некоторые системные объекты вместо полного пересоздания можно частично переинициализировать и тоже сэкономить.
Вот какой результать это дает на пуле из 16 запущенных Http серверов (HHVM), Nginx балансирует запросы (прогрев 50 000 запросов на пул):

Скрытый текст

nazar-pc@nazar-pc ~> ab -n5000 -c256 cscms.org:9990/uk
This is ApacheBench, Version 2.3 <$Revision: 1604373 $>
Copyright 1996 Adam Twiss, Zeus Technology Ltd, www.zeustech.net/
Licensed to The Apache Software Foundation, www.apache.org/

Benchmarking cscms.org (be patient)
Completed 500 requests
Completed 1000 requests
Completed 1500 requests
Completed 2000 requests
Completed 2500 requests
Completed 3000 requests
Completed 3500 requests
Completed 4000 requests
Completed 4500 requests
Completed 5000 requests
Finished 5000 requests

Server Software: nginx/1.6.2
Server Hostname: cscms.org
Server Port: 9990

Document Path: /uk
Document Length: 8497 bytes

Concurrency Level: 256
Time taken for tests: 5.716 seconds
Complete requests: 5000
Failed requests: 4983
(Connect: 0, Receive: 0, Length: 4983, Exceptions: 0)
Total transferred: 44046822 bytes
HTML transferred: 42381822 bytes
Requests per second: 874.69 [#/sec] (mean)
Time per request: 292.676 [ms] (mean)
Time per request: 1.143 [ms] (mean, across all concurrent requests)
Transfer rate: 7524.85 [Kbytes/sec] received

Connection Times (ms)
min mean[±sd] median max
Connect: 0 0 0.9 0 7
Processing: 6 284 215.9 241 976
Waiting: 6 284 215.9 241 976
Total: 6 284 215.8 241 976

Percentage of the requests served within a certain time (ms)
50% 241
66% 337
75% 409
80% 442
90% 623
95% 728
98% 829
99% 869
100% 976 (longest request)

875 запросов в секунду, это в 3.57 раза больше чем изначальный вариант с HHVM, что не может не радовать (иногда бывает на пару сотен больше запросов в секунду, бывает на пару сотен меньше, погода на десктопе бывает разная, но на момент написания статьи результаты таковы).

Так же есть перспективы для ещё большего увеличения производительности (например ожидается поддержка keep-alive и других вещей в ReactPHP), но тут уже многое зависит от проекта где это используется.

Ограничения

Так как мы сохраняем максимальную совместимость с любым существующим кодом — при асинхронном режиме при разных временных зонах пользователей нужно использовать их явно, иначе date() может вернуть неожиданный результат.
Так же пока не поддерживается загрузка файлов, но 2 pull request'а для поддержки multipart уже есть, в ближайшее время могут быть включены в react/http, тогда заработает и здесь.

Подводные камни

Главный подводный камень в таком режиме — утечка памяти. Когда после выполнения 1000 запросов потребление памяти было одно, а после 5000 на пару мегабайт больше.
Советы по отлову утечек:

  • Обрезать объем выполняемого кода до минимума, запустить 5000 запросов, логируя объем памяти после каждого выполнения, сравнить потребление
  • Добавить немного выполняемого кода, повторить
  • Продолжать до проверки всего кода, количество запросов можно опускать постепенно до 2000 (для того чтобы не ждать долго), но в случае когда есть сомнения — накинуть ещё несколько тысяч запросов будет не лишним
  • Несколько запросов может потребоваться для стабилизации потребления памяти, сначала до 100 запросов, иногда при запуске полной системы бывало до 800 запросов на стабилизацию потребления памяти, после этого объем потребляемой памяти перестает расти.
  • Так как ситуация не очень мейнстримная, может случиться так, что память течет не в вашем коде, а в сторонней библиотеке, либо вообще расширении PHP (PHP-CGI как пример) — тут можно пожелать удачи и не забывать про супервизор над сервером:)

Второе — соединение с БД — оно может оторваться, будьте готовы его поднимать при падении. Это совершенно не актуально при популярном подходе, тут же может создать проблем.
Третье — ловите ошибки и не используйте exit()/die() если только вы не имеете ввиду именно это.
Четвертое — вам нужно каким-то образом отделять глобальное состояние разных запросов если собираетесь работать с асинхронным кодом, если асинхронного кода нет — глобальное состояние достаточно просто подделать, главное не используйте зависимые от запроса константы, статические переменные в функциях и подобные штуки, если только не хотите внезапно сделать гостя админом:)

Заключение

С подобным подходом существенного роста производительности можно достичь либо без изменений, либо с минимальными (автоматический поиск и замена), а с Request/Response фреймворками это ещё проще сделать.
Прирост скорости зависит от интерпретатора и того, что код делает — при тяжелых вычислениях HHVM компилирует тяжелые участки в машинный код, при запросам ко внешним API можно использовать менее производительный асинхронный режим, но асинхронно грузить данные с внешнего API (если запрос к API занимает сотни милисекунд это даст существенный прирост в общей скорости обработки запросов).
Если есть желание попробовать — в CleverStyle CMS это и многое другое доступно из коробки и просто работает.

Исходники

Исходников не много, при желании можно модифицировать и использовать в многих других системах.
Класс в Request.php принимает запрос от ReactPHP и отправляет ответ, functions.php содержит функции для работы с глобальным контекстом (в том числе несколько специфических для CleverStyle CMS), Superglobals_wrapper.php содержит класс, который используется для массиво-подобных суперглобальных объектов, Singleton.php — модифицированная версия трейта, который используется вместо системного для создания системных объектов (он же и определяет какие объекты общие для всех запросов, а какие нет).

Автор: nazarpc

Источник


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js