Содержание статьи
Введение
H2O — очень молодой веб‑сервер. Первую публичную версию под номером 0.9 Кадзухо Оку представил всего пару месяцев назад в аккурат под католическое рождество. H2O прост, имеет скромный (почти базовый) набор возможностей и пока подходит разве что для хостинга блогов или работы в качестве reverse proxy. Функциональность сводится к реализации протоколов HTTP/1.0, HTTP/1.1 с поддержкой chunked-кодирования и HTTP/2 с поддержкой приоритетов и методов согласования соединения (NPN, ALPN, Upgrade и direct). Ну и конечно же, TLS, WebSockets, управление через YAML, общая оптимизация для отдачи статики и возможность включения в другие проекты в виде библиотеки.
Как и любой другой веб‑сервер, H2O очень просто установить и настроить:
$ wget https://github.com/h2o/h2o/archive/master.zip
$ unzip master.zip
$ sudo apt-get install build-essential cmake libyaml
$ cd h2o-master
$ cmake -DCMAKE_INSTALL_PREFIX=/usr/local
$ make
$ sudo make install
Стандартный конфиг выглядит так:
# Стандартные настройки сервераlisten: 8080listen: port: 8081 ssl: certificate-file: examples/h2o/server.crt key-file: examples/h2o/server.key# Конфиги виртуальных хостовhosts: # HTTP-хост с корневым каталогом в examples/doc_root и логами в консоль "127.0.0.1.xip.io:8080": paths: /: file.dir: examples/doc_root access-log: /dev/stdout # HTTPS-хост "alternate.127.0.0.1.xip.io:8081": listen: port: 8081 ssl: certificate-file: examples/h2o/alternate.crt key-file: examples/h2o/alternate.key paths: /: file.dir: examples/doc_root.alternate access-log: /dev/stdout
В целом все просто и стандартно, можно было бы идти дальше и пробовать следующий из миллиона таких же минималистичных веб‑серверов, если бы не одно но. H2O действительно очень быстр и существенно обгоняет nginx в скорости обработки запросов.
Кадзухо Оку
В узких кругах Кадзухо Оку известен в первую очередь как создатель браузера Plamscape (Xiino) для платформы Palm Pilot. Это был первый браузер для Palm OS, который впоследствии предустанавливали на свои устройства такие компании, как IBM и Sony. Также его перу принадлежит компилируемый в JavaScript-представление язык JSX, движок хранения для MySQL Q4M и сервер приложений Server::Starter для Perl-приложений.
Бенчмарки
Анонс H2O Кадзухо Оку сопроводил эффектным графиком, полученным с использованием двух Amazon-серверов c3.8xlarge (сервер и клиент). Данный график можно увидеть на изображении «H2O vs nginx», и он красноречиво показывает полный разгром nginx при размере отдаваемого контента от шести байт до десяти килобайт (с постепенным сближением результатов при увеличении размера контента).
Подробностей о методах тестирования автор не сообщил, но зато привел другие цифры, в этот раз полученные утилитой wrt (флаги '-c 500 -d 30 -t 1') при запуске сервера и клиента на одной машине в довольной извращенной конфигурации: Ubuntu 14.04 (x86-64) / VMware Fusion 7.1.0 / OS X 10.9.5 / MacBook Pro 15 (да, слоеный пирог). Согласно им при размере контента в шесть байт H2О обгоняет nginx почти в два раза, но при увеличении размера отдаваемых данных начинает сдавать позиции.
Сравнение с HTTP/2-серверами (tiny-nghttpd и trusterd) также показывает довольно значительное опережение H2О в скорости с последующим сближением с конкурентами при увеличении размера контента. Несколько других независимых измерений в целом демонстрируют ту же картину относительно HTTP/1.х и HTTP/2 с той же динамикой в сторону сближения результатов. Плюс показывают проблемы H2O с масштабированием больше, чем на два ядра (но это дело наживное).
В целом все это чрезвычайно интересно. Да, такие тесты не учитывают многих нюансов реализации веб‑сервера, связанных с непредсказуемыми ситуациями, и хотелось бы увидеть сравнение с использованием инструмента Tsung для сервера под нагрузкой в разных конфигурациях. Но на данном этапе это неважно, а важна именно корреляция между объемом отдаваемых данных и скоростью обработки запросов.
Базовые идеи H2O
Не надо быть экспертом в разработке веб‑серверов или их настройке, чтобы понять, что превосходство H2O в отдаче небольших объемов данных и потеря позиций при их увеличении — следствие запредельной оптимизации механизма парсинга HTTP-заголовков и подсистем, реализующих цепочку «получить запрос → сгенерировать ответ → отправить данные».
По словам самого автора, мотивом к созданию H2O послужил ожидаемый переход на протокол HTTP/2 и, как следствие, постепенный сдвиг парадигмы оптимизации отдаваемого контента от «давайте все сольем в один CSS/JS-файл» к обратной идее разбиения на множество мелких файлов. Причина тому в самой природе HTTP/2, а именно в его способности мультиплексировать канал передачи данных, позволяя отдавать несколько файлов одновременно с возможностью приоритезации.
Для HTTP/2 такой подход разбиения намного эффективнее модели «все в одном». Логичнее выставить максимальный приоритет CSS-файлам, описывающим шапку сайта, и небольшим JS-скриптам, которые должны быть выполнены первыми, и получить выигрыш в скорости отрисовки страницы на стороне клиента, чем заставлять его ждать, пока догрузится вся таблица стилей и все используемые на сайте JS-функции.
H2O — это в первую очередь HTTP/2-сервер, оптимизированный для отдачи множества мелких файлов. С этой задачей он, как мы выяснили, справляется просто на отлично, но как удалось достичь таких результатов? Об этом Кадзухо Оку рассказал в своей презентации, подготовленной для HTTP2 Conference, отметив четыре основных задачи, на которые типичный веб‑сервер тратит большую часть процессорных ресурсов:
- разбор входных данных;
- формирование ответа и логов;
- выделение памяти;
- управление тайм‑аутами соединений.
Как следствие, идея H2O была в том, чтобы приложить все возможные усилия для оптимизации этих совсем небольших участков кода, оставив за скобками все остальное. Вроде бы стандартный подход, известный любому программисту, но в оптимизации автор пошел далеко не стандартным путем.
Разбор HTTP-заголовков
Для парсинга HTTP-заголовков H2O использует высокопроизводительную библиотеку PicoHTTPParser за авторством самого Кадзухо. Она уже несколько лет применяется в Perl-библиотеке HTTP::Parser::XS, которую, в свою очередь, юзают такие проекты, как Plack, Starman, Starlet и Furl. Согласно бенчмарку 3p, PicoHTTPParser почти в десять раз быстрее среднестатистической реализации HTTP-парсера и по уровню скорости обработки данных всего на 20–30% отстает от стандартной функции языка си strlen(), весь код которой состоит из одного цикла, перебирающего символы строки в поисках спецсимвола \0.
PicoHTTPParser — это stateless-парсер, что делает его намного более быстрым, чем классические stateful-реализации. Вот, например, участок кода, в котором происходит поиск конца строки:
#define IS_PRINTABLE_ASCII(c) ((unsigned char)(c) - 040u < 0137u)static const char* get_token_to_eol(...){ while (likely(buf_end - buf >= 8)) { #define DOIT() if (unlikely(! IS_PRINTABLE_ASCII(*buf))) goto NonPrintable; ++buf DOIT(); DOIT(); DOIT(); DOIT(); DOIT(); DOIT(); DOIT(); DOIT(); #undef DOIT continue; NonPrintable: if ((likely((unsigned char)*buf < '\040') && likely(*buf != '\011')) || unlikely(*buf == '\177')) { goto FOUND_CTL; }}
Изюминка этой функции в том, что она обрабатывает данные целыми чанками (по восемь байт), внутри которых вообще не используются переменные. Поэтому машинный код, полученный при компиляции, будет намного компактнее того, который был бы получен в случае стандартного побайтового цикла с переменными для хранения счетчика итераций и текущего символа. На самом деле автор даже разобрал полученные ассемблерные листинги и выяснил, что каждое исполнение маркоса DOIT() — это всего четыре процессорные инструкции.
В целом парсер написан так, чтобы по минимуму использовать переменные для хранения промежуточного состояния и вместо этого полагаться на контекст исполнения, который определяется уровнем вложенности функций. Никакого выделения буферов внутри кода парсера нет, он всегда работает с переданным ему буфером и на выходе отдает массив структур ключ:значение, ссылающийся на разные участки того же буфера:
struct phr_header { const char* name; size_t name_len; const char* value; size_t value_len;};
Кроме того, незадолго до публикации первой версии H2O в парсер была добавлена поддержка SSE 4.2, что увеличило и без того высокую производительность еще на 60–90%.
Ответные сообщения и логи
Второе узкое место веб‑сервера — это код, формирующий ответные сообщения и логи. В HTTP/1.х (и частично в HTTP/2) ответ веб‑сервера представлен в текстовом виде вместе с HTTP-заголовками, поэтому для его генерации обычно используются функции семейства printf (форматирование строки). Типичный код ответа может выглядеть примерно так:
sprintf(buf, "HTTP/1.%d %d %s\r\n", minor_version, status, reason);
Ключевая проблема этого кода в том, что функция sprintf довольно сложна в своей реализации и сама по себе является достаточно развитым stateful-парсером, использующим аргументы переменной длины, учитывающим текущую локаль и многие другие нюансы. Один из подходов оптимизации — это вообще не использовать sprintf в данном участке кода и сформировать строку самостоятельно, сложив ответ из нескольких строк. Но автор H2O придумал более изощренный и универсальный метод.
В H2O используется специальный препроцессор языка си, который запускается еще до начала компиляции и заменяет все встреченные в коде обращения к функциям s(n)printf на оптимизированный для каждого конкретного случая код форматирования строки. Это примерно эквивалентно методу ручной оптимизации, но выполняется он автоматически.
Препроцессор, кстати говоря, опубликован как отдельный проект на GitHub, так что его может использовать в своих (и чужих) приложениях любой желающий. Для интенсивно работающего со строками кода он может дать серьезный прирост производительности. Тот же H2O после его применения смог обрабатывать примерно на 20% больше запросов, чем при использовании стандартной библиотечной реализации функции.
Управление памятью
Операции выделения/освобождения памяти всегда обходятся дорого: здесь свою роль играет и переключение контекста, и механизм поиска свободных страниц, и многие другие факторы. Поэтому для веб‑серверов и других приложений, которым важна производительность, уже давно придумывают техники оптимизации работы с памятью.
Тот же Apache, например, не использует стандартные функции malloc и free для выделения временных буферов для промежуточных данных и хранения отдаваемого контента. Вместо этого на каждый запрос данных единовременно выделяется большой блок памяти, который затем используется для аллокации буферов по мере надобности и полностью освобождается после окончания обработки запроса. Такой способ гораздо быстрее стандартных malloc/free, и он также применяется в H2O.
Часть кода H2O, отвечающая за выделение данных из блока (пула):
void *h2o_mem_alloc_pool(h2o_mem_pool_t *pool, size_t sz) { ... ret = pool->chunks->bytes + pool->chunk_offset; pool->chunk_offset += sz; return ret;}
Как видно, функция просто возвращает указатель на адрес в общем блоке и сдвигает указатель на свободную область памяти дальше (следующий запрос выделения памяти получит ссылку на этот адрес). Как уже было сказано выше, аналогичной функции для освобождения данных нет и весь блок (пул) освобождается целиком уже после обработки HTTP-запроса.
Тайм-ауты
Как и nginx, H2O основан на событийной модели обработки запросов, предполагающей наличие одного процесса исполнения на каждое процессорное ядро. Такая модель намного эффективней многопоточной (Apache), когда речь идет о тысячах и сотнях тысяч одновременных соединений. Она позволяет серверу расходовать гораздо меньше памяти и не тратить ресурсы на переключение контекстов.
Одна из особенностей такой модели — использование единой структуры для хранения значений таймеров, которые необходимы для закрытия «повисших» соединений, отмены слишком долгих операций ввода‑вывода и других. Для эффективного управления такой структурой (а речь, напомню, идет о 100K соединений) большинство веб‑серверов используют сбалансированные деревья, что считается эффективным и наиболее логичным решением.
Однако и в этот раз Кадзухо Оку пошел своим путем и реализовал используемый в H2O event-loop с привлечением простого связного списка из значений тайм‑аутов (по одному на каждый тип тайм‑аута). Как заявляет сам автор, такой подход позволил сделать H2O еще быстрее, а сама реализация event-loop обогнала известную реализацию libuv на 5–10%.
Простота и скорость тождественны
Кадзухо Оку постоянно подчеркивает, что залог скорости — простота и грамотный дизайн. Причем если с первым пунктом все понятно, то под вторым он подразумевает и четкое разделение кода приложения на модули, минимальное использование обратных вызовов процедур, использование подхода zero-copy, при котором память копируется только в том случае, если без этого не обойтись, а также некоторые другие известные подходы вроде инлайна критически важных функций.
Код H2O действительно отлично структурирован и четко разделен на минимально связанные друг с другом логические компоненты. Все они разделены на пять слоев:
- Library — библиотечные функции, включая работу с памятью, строками, сокетами, тайм‑аутами;
- Protocol — реализации протоколов передачи данных: HTTP/1.1, HTTP/2, WebSocket;
- Handlers — обработчики запросов, пока только file и reverse proxy;
- Output filters — обработчики выходных данных: chunked-encoder, deflate, reproxy;
- Loggers — системы ведения логов.
Логическое разделение позволяет не только существенно упростить поддержку кода, но и легко менять реализации различных компонентов, в том числе с целью проверки новой функциональности и внесения оптимизаций. Так, изначально H2O был основан на event-библиотеке libuv, но затем автор добавил собственную более производительную реализацию, а libuv осталась как опция, и ее всегда можно включить в код путем сборки со специальными флагами.
Микросервер
Одно из возможных применений H2O — это так называемые микросерверы, то есть компоненты большого HTTP-приложения, разбросанные по разным машинам. Протокол HTTP/2 в подавляющем большинстве случаев не подходит для их реализации в силу своей асинхронной природы. А вот небольшая высокопроизводительная реализация HTTP/1.1 в виде загружаемой (или встроенной) библиотеки годится для этой задачи как нельзя лучше.
Выводы
В целом H2O выглядит обнадеживающе. Он быстр, прост, имеет правильный дизайн, его код очень приятно читать. Это один из немногих рабочих и готовых к применению HTTP/2-серверов. Другое дело, что нельзя предугадать, как поведет себя сервер в реальной боевой задаче и как далеко сможет зайти его автор на пути оптимизации функциональности, которая еще будет добавлена в сервер (а предстоит сделать еще очень многое). Лично я уже занес сервер в список отслеживания на GitHub и буду наблюдать за тем, что из всего этого получится.