Атомный реактор в каждый сайт
Все слышали о том, что PHP создан, чтобы умирать. Так вот, это не совсем правда. Если захотеть — PHP может не умирать, работать асинхронно, и даже поддерживает честную многопоточность. Но не всё сразу, в этот раз поговорим о том, как сделать чтобы он жил долго, и поможет нам в этом атомный реактор! Атомный реактор — это проект ReactPHP, в описании указано «Nuclear Reactor written in PHP». На знакомство с ним меня подтолкнула вот эта статья (картинка выше оттуда). Я перечитывал её несколько раз на протяжении года, но никак не получалось добраться до имплементации на практике, хотя рост производительности более чем на порядок в перспективе очень радовал.
Исходное состояниеВ качестве подопытной системы выступает CleverStyle CMS, движок кэшировния APCu, версия в разработке, то есть установлены все возможные компоненты, в тестах открывается страница модуля Static pages.В качестве тестовой железки выступает рабочий ноутбук с Core i7 4900MQ (4 ядра, 8 потоков), ОС Ubuntu 15.04×64, дисковая подсистема состоит из двух 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/ukThis is ApacheBench, Version 2.3 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 requestsCompleted 1000 requestsCompleted 1500 requestsCompleted 2000 requestsCompleted 2500 requestsCompleted 3000 requestsCompleted 3500 requestsCompleted 4000 requestsCompleted 4500 requestsCompleted 5000 requestsFinished 5000 requests
Server Software: nginx/1.6.2Server Hostname: cscms.orgServer Port: 8080
Document Path: /ukDocument Length: 99320 bytes
Concurrency Level: 128Time taken for tests: 22.280 secondsComplete requests: 5000Failed requests: 4239(Connect: 0, Receive: 0, Length: 4239, Exceptions: 0)Total transferred: 498328949 bytesHTML transferred: 496603949 bytesRequests 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 maxConnect: 0 0 0.5 0 3Processing: 26 563 101.6 541 880Waiting: 24 559 101.3 537 872Total: 30 564 101.4 541 881
Percentage of the requests served within a certain time (ms)50% 54166% 55975% 57280% 58490% 75995% 79598% 81799% 829100% 881 (longest request)
Конкурентность 128, поскольку при 256 PHP-FPM просто падает.Теперь HHVM, для начала прогреем HHVM с помощью 50 000 запросов (почему), потом выполним тест:
Скрытый текст nazar-pc@nazar-pc ~> ab -n5000 -c256 cscms.org:8000/ukThis is ApacheBench, Version 2.3 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 requestsCompleted 1000 requestsCompleted 1500 requestsCompleted 2000 requestsCompleted 2500 requestsCompleted 3000 requestsCompleted 3500 requestsCompleted 4000 requestsCompleted 4500 requestsCompleted 5000 requestsFinished 5000 requests
Server Software: nginx/1.6.2Server Hostname: cscms.orgServer Port: 8000
Document Path: /ukDocument Length: 99309 bytes
Concurrency Level: 256Time taken for tests: 20.418 secondsComplete requests: 5000Failed requests: 962(Connect: 0, Receive: 0, Length: 962, Exceptions: 0)Total transferred: 498398875 bytesHTML transferred: 496543875 bytesRequests 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 maxConnect: 0 0 1.5 0 8Processing: 505 1019 102.6 1040 1582Waiting: 505 1017 102.9 1039 1579Total: 513 1019 102.5 1040 1586
Percentage of the requests served within a certain time (ms)50% 104066% 106875% 108080% 108790% 110895% 112698% 117999% 1397100% 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/ukThis is ApacheBench, Version 2.3 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 requestsCompleted 1000 requestsCompleted 1500 requestsCompleted 2000 requestsCompleted 2500 requestsCompleted 3000 requestsCompleted 3500 requestsCompleted 4000 requestsCompleted 4500 requestsCompleted 5000 requestsFinished 5000 requests
Server Software: nginx/1.6.2Server Hostname: cscms.orgServer Port: 9990
Document Path: /ukDocument Length: 99323 bytes
Concurrency Level: 256Time taken for tests: 16.092 secondsComplete requests: 5000Failed requests: 1646(Connect: 0, Receive: 0, Length: 1646, Exceptions: 0)Total transferred: 498418546 bytesHTML transferred: 496643546 bytesRequests 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 maxConnect: 0 0 0.9 0 6Processing: 100 804 308.3 750 2287Waiting: 79 804 308.2 750 2285Total: 106 804 308.1 750 2287
Percentage of the requests served within a certain time (ms)50% 75066% 84175% 94280% 99090% 118095% 138198% 172099% 1935100% 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/ukThis is ApacheBench, Version 2.3 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 requestsCompleted 1000 requestsCompleted 1500 requestsCompleted 2000 requestsCompleted 2500 requestsCompleted 3000 requestsCompleted 3500 requestsCompleted 4000 requestsCompleted 4500 requestsCompleted 5000 requestsFinished 5000 requests
Server Software: nginx/1.6.2Server Hostname: cscms.orgServer Port: 9990
Document Path: /ukDocument Length: 8497 bytes
Concurrency Level: 256Time taken for tests: 5.716 secondsComplete requests: 5000Failed requests: 4983(Connect: 0, Receive: 0, Length: 4983, Exceptions: 0)Total transferred: 44046822 bytesHTML transferred: 42381822 bytesRequests 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 maxConnect: 0 0 0.9 0 7Processing: 6 284 215.9 241 976Waiting: 6 284 215.9 241 976Total: 6 284 215.8 241 976
Percentage of the requests served within a certain time (ms)50% 24166% 33775% 40980% 44290% 62395% 72898% 82999% 869100% 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 — модифицированная версия трейта, который используется вместо системного для создания системных объектов (он же и определяет какие объекты общие для всех запросов, а какие нет).