«Мы говорим nginx, под­разуме­ваем про­изво­дитель­ность, мы говорим про­изво­дитель­ность — под­разуме­ваем nginx» — такой наве­янный совет­чиной лозунг как нель­зя луч­ше опи­сыва­ет ситу­ацию, сло­жив­шуюся в сре­де адми­нов. И с этим невоз­можно пос­порить. Точ­нее, было невоз­можно, пока поч­ти никому не извес­тный прог­раммист по име­ни Кад­зухо Оку (Kazuho Oku) не пред­ста­вил веб‑сер­вер H2O, лег­ко и неп­ринуж­денно уде­лав­ший nginx в тес­тах отда­чи ста­тики.
 

Введение

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: 8080
listen:
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 при раз­мере отда­ваемо­го кон­тента от шес­ти байт до десяти килобайт (с пос­тепен­ным сбли­жени­ем резуль­татов при уве­личе­нии раз­мера кон­тента).

H2О vs nginx
H2О vs 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() — это все­го четыре про­цес­сорные инс­трук­ции.

Часть ассемблерного листинга функции get_token_to_eol()
Часть ассем­блер­ного лис­тинга фун­кции get_token_to_eol()

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

struct phr_header {
const char* name;
size_t name_len;
const char* value;
size_t value_len;
};

Кро­ме того, незадол­го до пуб­ликации пер­вой вер­сии H2O в пар­сер была добав­лена под­дер­жка SSE 4.2, что уве­личи­ло и без того высокую про­изво­дитель­ность еще на 60–90%.

Производительность PicoHTTPParser в сравнении со стандартным stateful-парсером
Про­изво­дитель­ность PicoHTTPParser в срав­нении со стан­дар­тным stateful-пар­сером
 

Ответные сообщения и логи

Вто­рое узкое мес­то веб‑сер­вера — это код, фор­миру­ющий ответные сооб­щения и логи. В 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% боль­ше зап­росов, чем при исполь­зовании стан­дар­тной биб­лиотеч­ной реали­зации фун­кции.

Результат работы препроцессора qrintf
Ре­зуль­тат работы преп­роцес­сора qrintf
 

Управление памятью

Опе­рации выделе­ния/осво­бож­дения памяти всег­да обхо­дят­ся дорого: здесь свою роль игра­ет и перек­лючение кон­тек­ста, и механизм поис­ка сво­бод­ных стра­ниц, и мно­гие дру­гие фак­торы. Поэто­му для веб‑сер­веров и дру­гих при­ложе­ний, которым важ­на про­изво­дитель­ность, уже дав­но при­думы­вают тех­ники опти­миза­ции работы с памятью.

Тот же 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 и буду наб­людать за тем, что из все­го это­го получит­ся.

Оставить мнение