True FastCGI для PHP — ускорение Symfony и Zend Framework

// Сентябрь 9th, 2010 // Highload, PHP, Zend Framework

Начиная с PHP 5.3, язык стал готов к работе в режиме True FastCGI. Я решил попробовать эту возможность на практике… Ну и вот что из этого вышло.

В статье описана попытка использования технологии, сложности, которые пришлось преодолевать и некоторые бенчмарки, демонстрирующие возможный эффект перехода на True FastCGI.

PHP и Javascript часто используются вместе, а это мой единственный блог, поэтому, надеюсь, будет к месту.

PHP FastCGI против True FastCGI

FastCGI-модель, применяемая изначально для PHP, позволяет использовать только часть изначально заложенной в нее мощи.

Дело в том, что обычно FastCGI выглядит примерно так:

	Framework::init();
	 
	while($request = FastCGI::accept()) {
	  // обработать запрос, выдать страницу
	}
	 
	Framework::shutdown();

Запускается множество процессов, каждый из них один раз инициализуется, а затем в цикле обрабатывает поступающие запросы.

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

Эта модель — классическая для языков Perl, Ruby. Однако PHP изначально пошел другим путем. В этом языке интерпретатор полностью очищается между запросами и выполняет php файл «с нуля».
То есть, цикл вынесен из языка, и программист может лишь описать его внутреннюю часть, обработку запроса.

Почему PHP FastCGI ?

Действительно, почему? Первый ответ — потому что так проще, а PHP брал популярность простотой. Второй — потому что до 5.3 в PHP не было нормальной очистки памяти, включающей в себя очистку объектов от круговых ссылок.

Например, запустите следующий код в PHP 5.2. В нем создаются два объекта, ссылающиеся друг на друга, и один из них удаляется.

	class Foo {
	    function __construct()
	    {
	        $this->bar = new Bar($this);
	    }
	}
	 
	class Bar {
	    function __construct($foo = null)
	    {
	        $this->foo = $foo;
	    }
	}
	 
while (true) {
    $foo = new Foo();
    unset($foo);
	    echo number_format(memory_get_usage()) . "\n";
	}

PHP мгновенно (степень мгновенности зависит только от процессора ) съест всю доступную ему память.

А в PHP 5.3 наконец-то сделали нормальный сборщик мусора, и все будет ок.

Ну и, в-третьих, чтобы избежать перекомпиляции файлов при каждом запуске, созданы «опкод кешеры», которые частично нивелируют эффект повторной загрузки кода из одного и того же файла.

Почему True FastCGI эффективнее?

..Да просто потому, что есть возможность однократной инициализации FrameWork::init(). Кроме того, готовые структуры классов не исчезают из интерпретатора между запросами, так что нет необходимости их каждый раз перечитывать из опкод-кеша.

Для простых скриптов — там, где нет ни сложных классов, ни многоуровневой инициализации с конфигами, наверное, нет разницы. Но современные фреймворки содержат много файлов и делают массу вещей в процессе инициализации. И здесь «настоящий» FastCGI весьма полезен.

Symfony и PHPDaemon

Задача о переходе на True FastCGI — не теоретическая, а под конкретный проект с конкретным целевым фреймворком: Symfony. Современный Zend Framework тоже подошел бы, но проект именно на Symfony 1.4.

Для тестовой миграции выбран базовый проект: sandbox из поставки Symfony.

PHPDaemon

Как организовать True FastCGI ? Обычно в языках для этого существуют модули и библиотеки. Для PHP такого почти нет, но, просмотрев несколько недоделок, я нашел довольно серьезный проект подобного рода —PHPDaemon.

Да, его делает один девелопер со странной фамилией (ником), это минус, но делает он его уже пару лет и коммитит стабильно. Nginx, знаете ли, тоже один девелопер делает — и ничего, справляется Главное качество.

А по качеству — оказалось очень даже ничего. True FastCGI — лишь одна небольшая возможность мощного демона, который представляет собой асинхронный фреймворк типа Twisted. И, что самое главное, True FastCGI на нем не ликает (т.е. не течет память).

По опыту перевода большого приложения на Perl на FastCGI (года 3-4 назад) — это редкость, т.к. при прокрутке большого количества запросов в одном скрипте с памятью нужно обращаться осторожно. Некоторые перловые модули замечательно текут.

Проявляется это как непрерывное увеличение процесса в памяти по ходу приема запроса, и лечится либо фиксом модулей, либо рестартом процесса по достижении определенного размера.. А лучше и тем и другим. В PHPDaemon с этим все ок.

Кроме того, разработчик расположен к диалогу, говорит по-русски и, вообще, весьма адекватен.

Процесс скрещивания ежа с ужом

Фреймворк и PHPDaemon не заточены друг под друга. Вообще никак. Однако, скрещивание прошло почти без эксцессов.

PHPDaemon поддерживает протокол FastCGI, правильно ставит суперглобалы.. В общем, ведет себя как обычный сервер.

Для обработки входящих запросов вместо цикла используются каллбэки (PHP 5.3).

Есть два основных класса:

SymfonyApp extends AppInsance
Объект создается один раз как синглтон и предоставляет ряд каллбэков, которые включаются на различных стадиях работы. В частности, метод init(), используемый для изначальной инициализации фреймворка.
SymfonyRequest extends Request
Объект запроса — второй по важности. Его методы run() и onFinish() вызываются при обработке очередного пришедшего запроса. Основная работа происходит в run().

В конце статьи находятся исходники, а главное — файл phpdaemon/applications/SymfonyApp.php, который содержит приложение под PHPDaemon и весь описанный в этой статье код.

Основных требований к скрещиванию несколько.

  1. Отсутствие утечек памяти. Чтобы один запуск скрипта обрабатывал 10000 запросов без роста в объеме.
  2. Минимум интегрирующих патчей как на фреймворк Symfony, так и на PHPDaemon. Лучше вообще без патчей. И то и другое — 3rd party код, патчи к нему поддерживать совсем не хочется.
  3. Оно должно работать, по возможности, безглючно

Подробности скрещивания — в отдельной статье, если к ним будет интерес. В этой секции разберем основные моменты и подводные камни, на которые пришлось наступить во время миграции.

register_shutdown_function(каллбэк)

Вызывая эту функцию, фреймворк хочет сделать что-то в конце запроса. А при использовании PHPDaemon она вызовется в конце FastCGI-цикла.

Чтобы вызывать каллбэк когда нужно — заменим эту функцию на свою. А, чтобы ничего не патчить, используем расширение runkit. Его багфиксаный вариант, поддерживающий 5.3 находитя в репозитарии Дмитрия Зеновича http://github.com/zenovich/runkit. Спасибо, Дима!

Замена получается примерно такая:

runkit_function_copy ( 'register_shutdown_function', 'register_shutdown_function_native');
     
runkit_function_redefine('register_shutdown_function', '$arg', 'SymfonyApp::registerShutdown($arg);');

То есть, вместо родной функции будет вызываться наша, которая запомнит обработчик, а мы уж вставим их вызов в SymfonyRequest#onFinish.

create_function

Эта проблема была неожиданной. Дело в том, что фреймворк использует вызов create_function для фильтрации массива и некоторых других операций. А этот метод при каждом вызове создает новую функцию.

В результате получалось, что функции создаются, создаются (но не уничтожаются) и PHP кушает все больше памяти.

Что делать? Можно заменить все create_function на замыкания, тогда будет все в порядке. Ведь в отличие от глобальных функций, которые создает create_function, замыкания являются объектами типа Closure и находятся в текущей области видимости. Поэтому при выходе из блока они будут корректно очищены.

Но такая замена требует патча фреймворка. Поэтому выбрано другое решение.

Опять, используя runkit, я заменил вызов на свой, добавляющий кеширование. То есть, повторный запуск create_function с одинаковыми аргументами будет брать функцию из кеша, а не создавать заново.

Это описывается следующим блоком кода.

runkit_function_copy ( 'create_function', 'create_function_native'); 
	runkit_function_redefine('create_function', '$arg, $body', 'return __create_function($arg, $body);');
	 
	function __create_function($arg, $body) {
	   static $cache = array();
	    
	   if (!isset($cache[$arg.$body])) {  
	      $cache[$arg.$body] = create_function_native($arg, $body);
     }
	    
	   if (count($cache)>100) {
	         error_log("__create_function cache too large, probably different bodied functions, replace with closures");
	   }
	    
	   return $cache[$arg.$body];  
	}

Везде во фреймворке, create_function вызываются со статичным текстом в качестве аргументов и тела, наподобие такого:

// sfYamlInline.php
array_reduce($keys, create_function('$v,$w', 'return (integer) $v + $w;'), 0)

Функция с таким текстом тут же попадет в кеш и будет использована повторно.

Проверка if (count($cache)>100) поставлена на будущее и отслеживает ситуации, когда текст фукнции динамический и каждый раз создается новая функция.

Если это где-то будет происходить — можно будет легко отловить фрагмент и либо все-таки заменить его на замыкание, либо поправить как-либо еще.

Это — страховка на будущее, сейчас все работает без нее.

Внутренние утечки Symfony

Работа фреймворка в демон-редакции выглядит так. В начале — инициализация приложения (однократная):

class SymfonyApp extends AppInstance
	{
	...
	 public function init()
	 {
	     $this->configuration = ProjectConfiguration::getApplicationConfiguration('frontend', 'prod', false);
	   
	     $this->dbmanager = new sfDatabaseManager($this->configuration, array('auto_shutdown' => false));
	 
	 }

Затем — обработка запроса:

class SymfonyRequest extends Request
	{
	 ...
	 public function run()
	 { 
	  // создать контекст
	  // он заполняется данными запроса из суперглобалов
	  $instance = sfContext::createInstance($this->appInstance->configuration);
	  $instance->setDbmanager($this->appInstance->dbmanager);
	   
	  // обработать запрос, выдать результат
	  $instance->dispatch();
	 
	  return Request::DONE;
	 }
	...

И, чтобы память не текла, добавим дополнительную очистку в SymfonyRequest#onFinish, который демон вызывает после каждого запроса:

	...
	 public function onFinish() {
	     SymfonyApp::executeShutdown(); // вызвать все register_shutdown
	     
	    // почистить синглтоны
	    sfTimerManager::clearTimers();   
	    $this->appInstance->configuration->getEventDispatcher()->clear();
	  }
	...

Чистка синглтонов необходима для того, чтобы информация от прошлого запроса не накладывалась на текущий. Как ни странно, хватило этих двух вызовов, во всяком случае, для стандартного демо-приложения симфони. Плагины могут потребовать дополнительных чисток.

Бенчмарки

Бенчмарки проводились siege в режиме «1 воркер, много запросов без задержек». Я также пробовал увеличивать количество конкурентных соединений, но никаких дополнительных данных это не дает.

Причина в том, что серверный паттерн что у PHP FastCGI, что у True FastCGI один и тот же: pre-fork. То есть, с большой конкурентностью они будут справляться одинаково (тесты это подтвердили), так что вполне достаточно 1 потока непрерывных запросов.

Строка siege:

siege -c 1 -b -t 1m http://symfony.ru/ >/dev/null

Конечно же, был включен PHP-акселератор для режима FastCGI, APC/XCache без проверки stat. В режиме PHPDaemon акселератор был выключен.

Перед каждым тестом был предварительный разогрев — несколько секунд siege вхолостую.

Итак, результаты для обычного Symfony:

# siege -c 1 -b -t 1m http://symfony.ru/ >/dev/null
** SIEGE 2.70
** Preparing 1 concurrent users for battle.
The server is now under siege...

Lifting the server siege...      done.
Transactions:                    4976 hits
Availability:                 100.00 %
Elapsed time:                  59.08 secs
Data transferred:               4.60 MB
Response time:                  0.01 secs
Transaction rate:              84.22 trans/sec
Throughput:                     0.08 MB/sec
Concurrency:                    1.00
Successful transactions:        4976
Failed transactions:               0
Longest transaction:            0.03
Shortest transaction:           0.00

А вот — для True FastCGI:

root@debian6:~# siege -c 1 -b -t 1m http://phpdaemon.ru/ >/dev/null
** SIEGE 2.70
** Preparing 1 concurrent users for battle.
The server is now under siege...

Lifting the server siege...      done.
Transactions:                    9857 hits
Availability:                 100.00 %
Elapsed time:                  59.65 secs
Data transferred:              19.05 MB
Response time:                  0.01 secs
Transaction rate:             165.25 trans/sec
Throughput:                     0.32 MB/sec
Concurrency:                    1.00
Successful transactions:        9857
Failed transactions:               0
Longest transaction:            0.26
Shortest transaction:           0.00

Как видите — разница в два раза.

Более того — я добавлял переинициализацию фреймворка в метод run(), и все равно True FastCGI был впереди… Хотя уже не в 2 раза, а всего лишь в 1.5 раза быстрее.

Дополнительные подводные камни

Пробовал включить акселератор в режиме демона. Однако демон запускается из командной строки, поэтому XCache/Eaccelerator не заработали (можно немного пропатчить их сырцы — запашут). А APC вообще расстроил — в режиме enable_cli он течет по памяти, да и вообще тормозит, скрипты работают медленнее.

Еще одна проблема -профилинг с XDebug. PHPDaemon форкается, поэтому профиль XDebug содержит лишь демона, а не воркера. Как ее обойти — пока непонятно, ждем ответа разработчика XDebug, Derick’а. Если он проблему можно обойти, то продолжу миграцию.

Резюме

True FastCGI позволило ускорить простое Symfony-приложение в 1.5-2 раза. Это весьма неплохо.

Есть легкие фреймворки, которые стараются свести время на инициализацию к минимуму, выигрыш на них будет меньше. Но цена этого — как правило, меньшее удобство. Извечное противостояние: удобство + универсальность VS быстродействие.

True FastCGI позволяет избежать этой проблемы. Здорово, что в PHP 5.3 наконец-то стало возможно то, что большинство языков умеет годами — неликающий FastCGI-цикл.

Да, а вот исходники базового symfony и phpdaemon (+symfony), включая конфиги для nginx: sources.zip.

Потенциально можно также ускорить и Zend Framework, как будет время — постараюсь провести исследование на эту тему.

Автор статьи: Илья Кантор

Share

Спасибо!


Если вам помогла статья, или вы хотите поддержать мои исследования и блог - вот лучший способ сделать это:


Комментировать