Или об особенностях управления отдаваемым контентом в PHP.
Поводом для данной статьи послужило двухдневное исследование, результаты которого показали, что безобидные по своей производительности функции echo и print на самом деле могут работать очень долго и их производительность зависит от качества интернета конечного пользователя.
Начну с того, что если бы мне такое сказали вчера, то я покрутил бы сам у этого человека пальцем у виска, однако серия проведенных тестов неумолимо свидетельствует об этом.
Генерация страницы: 1 секунда на мощном сервере и 200 мс. на слабом.
Всё началось с того, что я внедрил самописный профайлер в CakePHP фреймворк и встроил туда функцию подсчёта интервалов выполнения после основных логических частей кода. На локальном сервере всё работало хорошо, профайлёр показывал 200-300 мс., но на продакшене (сервере гораздо более мощном, на который мы ещё не нагнали посетителей) время выполнения было иногда 1-3 секунды!
Применив любимый способ отладки производительности:
- $microtime = microtime(true);
- // Некий участок
- echo (microtime(true) - $microtime);
выявил, что самая медленная конструкция это строка:
- print $out;
которая за раз пуляет всю страницу.
Дальнейший поиск по интернету показал, что я не одинок и проблема давно известна, и описана 6 лет назад в багах PHP. Согласно данному багу, проблема возникает при отправке за один раз слишком большого текстового блока, около 11-32 Киб.
На проблему влияет некий Nagle algorithm, который задерживает отправку пакета пользователю. Отключается который только при создании сокет-сервера, то есть в исходном коде Apache. Поэтому следующих два дня я потратил на конкретное изучение проблемы с целью понять причину и найти возможные варианты исправления.
Скрипт для обнаружения проблем.
Итак, согласно приведённому выше багу, я написал следующий тестовый скрипт:
- $index = !empty($_GET['index']) ? $_GET['index'] : 1;
- $example_output = str_repeat(str_repeat("*", 1024), $index);
-
- $microtime = microtime(true)*1000;
- echo $example_output;
- $interval = microtime(true)*1000 - $microtime;
-
- echo '<br>Display Length: ', $index, ' KiB.<br>';
-
- if($interval < 100 && $_GET['index'] < 100)
- echo '<meta http-equiv="refresh" content="1; url=?index='.($index + 1).'" />';
-
- echo 'Reach end file: ', round($interval, 2), ' ms.'."<br>\n";
При запуске скрипта, он запрашивает одну и туже страницу в браузере, выводя в него каждый раз всё больший и больший блок бесполезных текстовых данных до тех пор, пока время выполнения функции echo меньше 100 мс.
Получаем интересный результат, скрипт выводит для блока в 11 Киб:
*****
Display Length: 11 KiB.
Reach end file: 0.07 ms.
а для блока в 12 Киб:
*****
Display Length: 12 KiB.
Reach end file: 348.92 ms.
При этом данная проблема не воспроизводится стабильно. Запускаем тот же скрипт с американской машинки — проблема начинает воспроизводиться с 13 Киб. Запускаем с канадской (там же где стоит сервер) — нет проблем при любом значении.
Дальнейшие эксперименты показали, что на значение 348.92 ms также влияет текущая загруженность интернета, ибо с американской машинки значениях хоть и большие, но в разы меньшие, чем с белорусской.
Отдача контента пользователю шаг за шагом.
Таким образом постепенно у меня сформировалась картина того, как происходит отдача контента в PHP.
Итак, когда нет никаких задержек:
Обозначения схемы:
- Зелёный — обработка
- Жёлтый — ожидание
- Красный — обмен данными
- Синяя полоса — PHP shutdown интервал
Шаги:
1. Посетитель посылает запрос.
2. Запрос обрабатывается Apache`ем.
3. Начинается обработка запроса в PHP.
4. Выполняется echo, а затем весь оставшийся код в PHP файлах.
5. Параллельно Apache передаёт данные пользователю.
6. Начинается PHP shutdown интервал. Для начала вызываются функции, зарегистрированные через register_shutdown_function.
7. Затем вызываются все деструкторы. Происходит освобождение памяти от всех объектов.
8. PHP закрывает сессию пользователя (имеется ввиду автоматический вызов session_write_close).
9. Apache закрывает сокет.
10. Посетитель получает уведомление об окончании соединения.
В проблемной ситуации отдача контента происходит следующим образом:
У нас появляются следующие изменения в текущих:
4a. Выполняется echo. И ждёт сигнала с Apache.
4b. Apache передаёт данные пользователю, и пока не отправит всё, не происходит выхода из операции echo.
8a. PHP процесс отправляет все оставшиеся выходные потоки, если есть, а затем ждёт команды завершения от апача.
8b. Apache посылает все оставшуюся информацию.
8c. PHP закрывает сессию пользователя.
Таким образом на шаге 4 я и столкнулся с ситуацией, когда простейший вывод зависит от скорости передачи контента посетителю. При этом если мы обратим внимание на примерный график загрузки памяти, то PHP простаивает в момент когда он ещё не начинал глобальное освобождение ресурсов.
Об управлении выходным потоком.
Дальнейшее исследование особенностей выходного потока приводит нас к статье о настройках выходного потока PHP, а также к куче сообщений «умных дядек» на форумах, что ставьте output_buffering=200K и решайте таким образом все проблемы.
Но рассмотрим более детально, на что мы можем повлиять. Существуют следующие переменные конфигурации PHP:
- output_buffering — буфер выходного потока.
- output_handler — перенаправление выходного потока в функцию.
- implicit_flush — принудительная посылка контента после каждой операции вывода.
Принудительная посылка нам ничего не даёт, поскольку у нас зависание в самой операции вывода. Да и перенаправление в функцию нам не нужно. А вот установка переменной output_buffering частично решает нашу проблему, поскольку мы переносим тормоза на PHP shutdown интервал, гарантируя при этом полное выполнение логики до.
Если ставить эту переменную в определенное значение:
php_value output_buffering 131072
то нужно подобрать такое значение, которое больше чем размер какой-либо страницы, что не удобно. Поэтому лучше позволить ей динамически подбирать размер:
php_flag output_buffering On
После применения этого «лекарства» имеем следующий график работы:
То есть, мы добились только следующего:
- Зависание PHP происходит после отработки основной логики.
- PHP в среднем расходует меньше памяти, поскольку в момент единственного простоя почти вся она освобождена.
Мнимая таблетка.
Неудовлетворённый результатом, я решил поискать ещё решения по данной проблеме и нашёл предложение бить вывод на блоки, каждый из которых выводить через echo. Обрадовавшись, я попробовал, и о чудо, для 11 Киб у меня исчезла полностью задержка на PHP стороне. Но к несчастью, при суммарной отдаче контента размером более 18 Киб она снова появилась и дальше опять уже не важно бъётся он на блоки или нет.
Итоги.
Начиная с 1984 люди мучаются с алгоритмом Nagle, данная проблема не обошла и PHP и пока не видно способа её решения. Можно только немного минимизировать потери, в случае, если она у вас воспроизводится.
Послесловие или коллективный разум решает.
Спасибо все хабраюзерам за реакцию на данную статью, это помогло понять в этой проблеме ещё один момент и осознать некоторые описанные заблуждения.
Для начала всем кто пытался воспроизвести и не смог. Я уже научился воспроизводить локально без проблем. Для этого мой тестовый пример записываем в файл, а затем используя wget, качаем медленно кусок размером в 128Киб и наслаждаемся.
wget --limit-rate=1K www.test.lo/nagle_test.php?index=128
Display Length: 128 KiB.
Reach end file: 57234.61 ms.
А теперь работа над ошибками, которая возникла у меня после чтения коментариев и написания этого последнего теста:
- Алгоритм Nagle`а тут действительно не причём, поскольку он используется при малых объёмах данных, кроме того для модуля prefork всегда стоит значение TCP_NODELAY, которое и означает, что он всегда выключен. Возможно, если бы он был включен, то у нас могли возникать проблемы, например:
echo '*';
flush();
не возвращало бы в броузер ничего
там - Cуть задержек состоит в том, что Apache не возвращает управление при операциях вывода PHP, поскольку имеет небольшой промежуточный буфер для пересылки данных и, кстати, вовсе не обязан делать его большим, поэтому он и забирает данные по чуть-чуть не позволяя продолжать работу.
- Решать данную проблему надо, поскольку мы минимизируем среднее время жизни PHP, что позволяет нам обрабатывать одновременно большее число пользователей. Естественно без фанатизма, на проектах с не очень большой загрузкой это решать не надо.
- Решать данную проблему можно настраивая софтверный лоад балансер или реверс-прокси с помощью Apach, nginx, lighttpd. Нужно только не забыть почитать документацию о размерах буферов. Решать данную проблему правильно так, поскольку мы минимизируем нагрузку на сервера, на которых производится вычисление.
- Использовать output_buffering = On не всегда правильно, поскольку мы можем отдавать файл, и нам в этом случае не важны задержки в вычислениях, поскольку основная наша задача — отдать файл. К тому же в этом случае файл загружается в память, что тоже плохо.
- Есть вещи «оттягиващие» наступление проблемы. Это во первых: — бить отдаваемый контент по 8Киб (не знаю почему, но помогает). А во-вторых: использовать сжатие gzip. Хотя предпочитаю управлять способом отдачи на стороне веб-сервера, но тем не менее.
- Из самых простых решений — установить sendbuffersize в настройках Apache, но применить эту настройку можно, к сожалению, только полностью перезапустив веб-сервер и влияет она на все хосты.