Pull to refresh

Функция echo в PHP может выполняться более 1 секунды

Reading time 6 min
Views 13K

Или об особенностях управления отдаваемым контентом в PHP.


Поводом для данной статьи послужило двухдневное исследование, результаты которого показали, что безобидные по своей производительности функции echo и print на самом деле могут работать очень долго и их производительность зависит от качества интернета конечного пользователя.

Начну с того, что если бы мне такое сказали вчера, то я покрутил бы сам у этого человека пальцем у виска, однако серия проведенных тестов неумолимо свидетельствует об этом.



Генерация страницы: 1 секунда на мощном сервере и 200 мс. на слабом.


Всё началось с того, что я внедрил самописный профайлер в CakePHP фреймворк и встроил туда функцию подсчёта интервалов выполнения после основных логических частей кода. На локальном сервере всё работало хорошо, профайлёр показывал 200-300 мс., но на продакшене (сервере гораздо более мощном, на который мы ещё не нагнали посетителей) время выполнения было иногда 1-3 секунды!

Применив любимый способ отладки производительности:
  1. $microtime = microtime(true);
  2. // Некий участок
  3. echo (microtime(true) - $microtime);

выявил, что самая медленная конструкция это строка:
  1. print $out;

которая за раз пуляет всю страницу.

Дальнейший поиск по интернету показал, что я не одинок и проблема давно известна, и описана 6 лет назад в багах PHP. Согласно данному багу, проблема возникает при отправке за один раз слишком большого текстового блока, около 11-32 Киб.
На проблему влияет некий Nagle algorithm, который задерживает отправку пакета пользователю. Отключается который только при создании сокет-сервера, то есть в исходном коде Apache. Поэтому следующих два дня я потратил на конкретное изучение проблемы с целью понять причину и найти возможные варианты исправления.

Скрипт для обнаружения проблем.


Итак, согласно приведённому выше багу, я написал следующий тестовый скрипт:
  1. $index = !empty($_GET['index']) ? $_GET['index'] : 1;
  2. $example_output = str_repeat(str_repeat("*", 1024), $index);
  3.  
  4. $microtime = microtime(true)*1000;
  5. echo $example_output;
  6. $interval = microtime(true)*1000 - $microtime;
  7.  
  8. echo '<br>Display Length: ', $index, ' KiB.<br>';
  9.  
  10. if($interval < 100 && $_GET['index'] < 100)
  11.   echo '<meta http-equiv="refresh" content="1; url=?index='.($index + 1).'" />';
  12.  
  13. 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 good output buffering
Обозначения схемы:
  • Зелёный — обработка
  • Жёлтый — ожидание
  • Красный — обмен данными
  • Синяя полоса — 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. Посетитель получает уведомление об окончании соединения.

В проблемной ситуации отдача контента происходит следующим образом:
PHP output buffering problems
У нас появляются следующие изменения в текущих:
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 output buffering problem solution
То есть, мы добились только следующего:
  • Зависание 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, но применить эту настройку можно, к сожалению, только полностью перезапустив веб-сервер и влияет она на все хосты.
Tags:
Hubs:
If this publication inspired you and you want to support the author, do not hesitate to click on the button
+139
Comments 161
Comments Comments 161

Articles