Обработка большого количества сетевых соединений всегда
затруднительна. Мало того, не существует стандартных решений,
подходящих для проблем любого вида, в которых возникает большое
количество соединений. В этой статье пойдет речь о новом программном
интерфейсе, появившемся в FreeBSD, на примере организации web-сервера
при условии, что один компьютер все еще в состоянии обработать
поступающие к нему запросы.
Введение в проблему
Для большинства сайтов в русском интернете не встает вопрос
производительности. Сайт какой-либо чулочной фабрики вряд ли будет
сильно посещаемым, поэтому его создают только как дань моде и ничего
больше. Тем не менее, наиболее популярные ресурсы постоянно
сталкиваются с проблемой большего количества посетителей, чем они
могут обслужить.
Если для маленьких сайтов можно выделить некоторые стандартные
решения, такие как PHP или ASP, то большие сайты вынуждены
разрабатывать подобные решения самостоятельно. В этой статье будет
рассмотрен достаточно частный случай обработки запросов --- раздача
статической части сайта (к примеру, картинок) посетителям.
Кстати, на самом деле редко можно встретить сайт, который не
изменяется во времени, следовательно говорить о статичности можно
только с оговоркой о времени, в течение которого эта статичность
соблюдается. Практически любой сайт в интернете можно представить в
виде фиксированного и неизменного в течение некоторого промежутка
времени набора страниц, который отдается посетителю. Исключениями
являются несколько сильно нагруженных сайтов, вид страниц которых
зависит не от времени, когда пришел посетитель (к примеру, новости на
заглавной странице), а от личных предпочтений посетителя (как это
сделано в проекте "Мой Яндекс"), там действительно требуется чтобы
каждая страница создавалась бы по запросу посетителя. В остальных
случаях важно кеширование данных, временное представление их в
статическом виде, для того, чтобы серверам не требовалось бы
производить лишние вычисления при создании одной и той же страницы.
Другим ярким примером статических данных являются картинки. Мало того,
картинки, в отличие от текста, не требуют для себя никаких действий по
перекодировке "на лету" для посетителя (как это делает web-сервер
"Русский Apache"), поэтому обработка запросов посетителей web-сайтов
на картинки, составляющие часть содержимого страниц, значительно
проще, чем обработка запросов на текст тех же самых страниц. При этом
важно понять, что web-сервер с большим количеством возможностей, таких
как виртуальные сервера, исполнение cgi-скриптов, перекодировка
страниц, подключение различных модулей и т.д. не может эффективно
обрабатывать запросы на те же самые картинки, причем именно в
следствие слишком большого количества неиспользуемых возможностей. В
то же время, web-сервер, который способен без обработки отдавать
некоторую часть содержимого, очень прост в реализации. Именно о
способах реализации подобных web-серверов и пойдет речь далее в
статье. Понятно, что практически все, написанное ниже, справедливо для
любых сетевых приложений, картинки выбраны только в качестве примера.
Надо понимать, что внутреннее устройство web-сервера (программы)
должно предполагать, что несколько запросов могут поступить
одновременно. То есть, на сайт может обратиться посетитель из офиса
какой-нибудь крупной компании в Москве с хорошим подключением к
интернету и посетитель, подключившийся к сети по модему со скоростью,
к примеру, 19200, провайдер которого очень сильно экономит на своих
каналах. Посетитель с хорошим соединением не должен ждать, пока
web-сервер обслужит посетителя с плохим соединением, как, впрочем, и
наоборот. Таким образом, web-сервер должен "помнить" о по крайней мере
о том, какой посетитель какой документ запросил и сколько байт этого
документа он уже получил.
При использовании стека протоколов TCP/IP посетитель идентифицируется
web-сервером по своему ip-адресу, порту на клиентской машине и
ip-адрему с портом на серверной машине (у сервера может быть несколько
ip-адресов). Обычно, на серверной машине выделяется по одному порту на
каждого из посетителей сайта в текущий момент, следовательно работать
с ними можно пользуясь номерами портов и используемым ip-адресом. Вся
эта информация скрывается внутри сокетов, которыми можно оперировать
как обычными файлами.
Можно создавать для каждого посетителя сайта отдельный процесс с
копией web-сервера. Если это делать каждый раз по приходу посетителя и
потом процесс уничтожать, то такой подход становится чрезвычайно
расточительным, в связи с тем, что вызов для создания процессов,
fork(), является очень дорогостоящим: происходит создание копии
процесса-родителя (который вызвал fork()), что приводит к перемещениям
больших объемов данных из одного места оперативной памяти в другое.
Можно сразу же создать некоторое количество web-серверов в отдельных
процессах и передавать им управление при поступлении новых запросов.
Этот способ значительно лучше, тем не менее он хорошо подходит для
процессов, в которых время обработки запросов само по себе
значительно, а в данный момент мы рассматриваем обработку статических
данных, когда большую часть затраченного времени составляет именно
работа с сетью.
Следующий вариант состоит в создании нескольких потоков управления
внутри одного процесса. Это так же не является хорошей идеей в случае
обработки статических данных, потому что очень много процессорного
времени будет теряться на переключение процессора с одного потока на
другой, в то время как каждый из этих потоков будет занят лишь
ожиданием готовности клиента и операционной системы к приему или
передаче данных. В случае большого количества одновременных запросов
это будет очень медленно.
Недостатки select() и poll()
Остается действовать в пределах одного процесса и организовывать цикл
обработки сообщений от сетевого окружения операционной системы. Чаще
всего используются системные вызовы poll() или select(). Они во многом
похожи, поэтому я только рассмотрю select() в качестве примера, тем
более что poll() медленнее, чем select() (передается больше
параметров).
Необходимо поместить в rfds те файловые дескрипторы, из которых
требуется прочитать данные, а в wfds --- те, в которые требуется
записать данные. При этом max_fd --- максимальный файловый дескриптор,
помещенный в эти множества. Сам вызов select() выглядит примерно таким
образом:
select(max_fd + 1, &rfds, &wfds, NULL, &tv);
select() ждет максимум столько времени, сколько указано в tv, после
чего возвращает модифицированные множества rfds и wfds (и еще одно
множество, вместо которого в данном случае передан NULL), которые
теперь содержат в себе только файловые дескрипторы, готовые для чтения
или записи соответственно. Таким образом, часть цикла после select()
выглядит как проверка соответствующих дескрипторов на принадлежность
множествам, примерно вот так:
for( ... )
if(FD_ISSET(fd, &rfds)) { ... }
for( ... )
if(FD_ISSET(fd, &wfds)) { ... }
/*
* ...
*/
}
При этом fd это переменная, принимающая значения каждого из
дескрипторов, которыми были инициализированы множества rfds и wfds.
Необходимо обратить внимание на то, что конкретно происходит каждый
раз, когда выполняется внутренность цикла. Во-первых, инициализация
множеств. fd_set является битовым массивом, где индексом служит
числовое значение файлового дескриптора. Эти массивы копируются из
адресного пространства процесса в адресное пространство ядра при
вызове select() и потом копируются обратно при возврате. Надо
отметить, что подобные операции достаточно длительны и для их
сокращения, в select() передается максимальный дескриптор --- на самом
деле копируется часть массивов от нулевого до максимального
дескриптора.
Кстати сказать, принципиальное отличие poll() от select() лишь в том,
как передаются аргументы. В случае poll() это массив структур, а не
битовый массив (множество).
Понятно, что при большом количестве соединений количество открытых
дескрипторов увеличивается и, тем самым, копируется все большее и
большее количество данных из процесса в ядро и обратно. При этом,
внутри ядра точно так же производятся какие-то действия для
преобразования битовых массивов в свои внутренние структуры (и
последующего их удаления, когда приходит время возврата результатов
вызывающему процессу). Собственно, poll() будет медленнее при большом
количестве открытых дескрипторов, потому что размер копируемых данных
на порядок больше, чем в случае select().
Затем следует "пробежка" по множествам с целью поиска "готовых"
дескрипторов. Опять же, в случае большого количества соединений эта
операция достаточно длительна.
Если на каждую запись или чтение выполняется какая-то своя длительная
операция, со своими внутренними циклами, ветвлениями и прочим, то все
это не особенно важно. Но если обработка полученных дескрипторов
сводится к копированию данных из внутренних буферов в дескрипторы и
обратно, то выполнение бесконечного цикла обработки select(),
копирования данных при вызове, а так же пробежка по битовым массивам
при помощи макросов FD_ISSET() становится, по сути, единственными
действиями, которые выполняет web-сервер. На самом деле, именно эти
операции ограничивают его производительность в случае обработки
запросов на "простое, не изменяемое во времени содержимое", например,
картинки.
Понятно, что проблема опять же в количестве открытых соединений (как и
в случае обработки динамического содержимого), но тут можно произвести
некоторую оптимизацию, например при помощи такой возможности HTTP, как
keep-alive. Эта возможность позволяет обрабатывать несколько
HTTP-запросов в пределах одного TCP-соединения (в то время как обычные
запросы разрывают соединение). Очень полезная возможность, так как
обычно на одной странице находится несколько картинок, которые
необходимо выкачать посетителю web-сайта и если на каждую из них
каждый посетитель установит несколько соединений с сервером, то
количество дескрипторов в цикле выше возрастет, в то же время, если на
все картинки посетитель установит только одно соединение, то
количество соединений упадет (в идеале) во столько раз, сколько в
среднем располагается картинок на страницах сайта. И при этом не стоит
забывать, что сократится количество данных внутри ядра, отвечающих за
передачу информации через стек протоколов TCP/IP, а так же уменьшится
объем передаваемой служебной информации TCP/IP.
Тем не менее, это все равно не выход. Точнее, не принципиальный выход:
все равно, в принципе, возможен механизм, исключающий лишние циклы и
лишние копирования, так как внутри ядра информация о дескрипторах
доступна в значительно более простом виде, чем подобные множества.
Напрашивается решение в виде внесения web-сервера внутрь ядра
операционной системы. Этот подход реализован в khttpd или tux для
Linux. На мой взгляд, решение совершенно некрасивое и, кроме того,
неудобное, потому что, к примеру, перезапустить подобный сервер можно
только рестартом ядра (или при перезагрузке компьютера). Кроме того,
писать подобное программное обеспечение самостоятельно (потому что
такие задачи возникают постоянно и для их решения обычно берется за
основу какой-нибудь существующий простой и быстрый web-сервер)
затруднительно в силу неудобства отладки. И, наконец, последний довод:
любое изменение ядра операционной системы, которое нужно только одному
пользователю этой операционной системы, никогда не войдет в
официальную и поддерживаемую версию операционной системы. А это
чревато тем, что пользователь так и останется со старой операционной
системой еще долгое время или будет изменять ядро в каждой новой
версии.
Краткое описание механизма kqueue
Другое решение заключается в предоставлении программного интерфейса
оповещения процесса о событиях иного вида, чем описанный выше. Именно
этот путь был реализован в FreeBSD при помощи введения нового
механизма оповещения процесса о событиях, произошедших в ядре
операционной системы, называемого kqueue. Идея состоит в том, что
процесс в какой-то момент времени оповещает ядро о том, события какого
вида и для каких объектов он хотел бы отслеживать, после чего
посредством специального вызова получает список объектов,
удовлетворяющих этому требованию.
Такое решение позволяет избавиться от постоянного создания битовых
массивов перед вызовом функции, проверяющей события, и избавляет от
лишних проверок после вызова. Кроме того, несколько упрощается работа
внутри ядра.
Кроме того, механизм kqueue позволяет отслеживать значительно большое
количество событий в операционной системе, чем select() или poll() и
является расширяемым таким образом, что в будущем, вполне вероятно,
будет возможно получение напрямую событий от драйверов устройств.
Использовать kqueue не сложнее, чем select(). Сама очередь событий
создается при помощи вызова kqueue():
int kq;
...
kq = kqueue();
Полученный идентификатор является обычным дескриптором.
Зарегистрировать событие и получить список совершившихся событий можно
следующим образом:
kevent kq_change_list[NCHANGE];
int kq_chlist_used = 0;
kevent kq_events[NEVENTS];
int n, i;
switch(kq_events[i].filter)
{
case EVFILT_READ: /* чтение из дескриптора kq_events[i].ident */
case EVFILT_WRITE: /* запись в дескриптор kq_events[i].ident */
...
}
}
kq_chlist_used = 0;
По сравнению с select(), можно видеть, что исчезла обязательная
инициализация массивов в начале цикла (предварительный EV_SET перед
kevent() приведен только в качестве примера установки события, событие
регистрируется только один раз, а не при каждой итерации цикла) и
исчезла "пробежка" по всем интересующим дескрипторам, так как в
kq_events не будет содержаться лишних дескрипторов.
Механизм kqueue специфичен для FreeBSD и в других операционных
системах его нет, хотя в некоторых коммерческих представителях
семейства Unix есть аналогичные решения (точнее, решающие похожие
задачи). Тем не менее, так как изначально задача стояла как обработка
большого числа запросов на статическую информацию, для ее решения
вполне возможно выделить отдельный сервер, на котором была бы
установлена операционная система FreeBSD и работал бы web-сервер на
основе kqueue.
На сегодняшний день только у одного публично доступного web-сервера
thttpd есть поддержка kqueue. Кроме того, мне известна модификация
web-сервера mathpod для kqueue.
Загрузка процессора при смене select() на kqueue уменьшается на
порядок. Это очень хороший результат, хотя стоит помнить о том, что
зависимость загрузки процессора от количества обрабатываемых
соединений не является линейной. Впрочем, численное сравнение этих
методов не является целью данной статьи, интересующиеся могут
воспользоваться ссылками в конце для более подробного сравнения
использования select()/poll() и kqueue.
Подведение итогов
Итак, при обработке запросов на неизменяемую часть содержимого
web-сайта, необходимо использовать иной web-сервер, чем тот, который
обрабатывает тексты и генерирует страницы сайта. Если физический
сервер один, то логично разделить обработку запросов на страницы сайта
и картинки (к примеру) по портам. Например, Apache на 80 порту для
текстов и thttpd или mathpod на 8080 порту для картинок. Если же
нагрузка настолько велика, что основной web-сервер "мешается"
web-серверу для картинок, то под обработку подобных запросов придется
выделить отдельный физический сервер (таким образом, разделение
запросов будет происходить по доменным именам или ip-адресам).
Список маленьких web-серверов, а так же их сравнительные
характеристики, можно найти на официальной страничке web-сервера
thttpd (ссылка в конце статьи). Для демонстрации сравнительных
характеристик приведу сокращенную версию этой таблицы (данные на 1998
год, но интересны не абсолютные, а относительные единицы измерения):
http://www.acme.com/software/thttpd/benchmarks.html
Полную версию этой таблицы можно найти здесь (http://www.acme.com/software/thttpd/benchmarks.html).
Еще раз напоминаю, что данные на 1998 год и, следовательно, thttpd
тестировался без kqueue. Кроме того, все подобные тесты проводятся "в
вакууме", то есть с одного-двух ip-адресов в локальной сети и,
следовательно, нагрузки на реализацию стека протоколов TCP/IP не
такие большие, как в реальной жизни. Таким образом, в 1998 реальные
цифры были меньше, чем приведенные в таблице и отличаться они могли в
несколько раз при росте количества одновременных соединений (для
маленького количества соединений цифры отличались бы не сильно).
Еще одно интересное место в этой таблице, на которое хочется обратить
внимание --- производительность web-серверов, написанных на Java. Это
место я оставлю без комментариев.
На мой взгляд, для сервера раздачи картинок лучше всего подходят:
* thttpd, так как в нем есть поддержка kqueue изначально.
* mathpod, потому что в нем есть нормальная реализация HTTP
keep-alive. В случае замены select() на kqueue становится более
предпочтительным, чем thttpd. Кроме того, он проще чем thttpd (в
два раза меньше строчек исходного кода), что тоже является
несомненным плюсом. Если из него изъять кучу ненужных
возможностей, таких как поддержка виртуальных серверов, то лучше
варианта не найти.
Резюме
Реальное применение kqueue на загруженном веб-сервере помогло
увеличить его производительность как минимум в два раза (учитывая то,
что он не только отдает картинки, но и предварительно их обсчитывает).
Точное увеличение при замене его на сервере, отдающего уже сжатые
картинки, неизвестно, потому что даже в локальной сети не удалось
нагрузить компьютер на все 100%. Так что --- рекомендую.
PS
В свое время Gregory Liokumovich рассказывал
(http://www.kalinin.ru/programming/network/12_12_00.shtml)
о событийной модели в WinSock, которая очень похожа, как я себе
это понимаю, на kqueue. С другой стороны, я не знаком с внутренним
устройством Windows так, чтобы утверждать насколько велика разница
в производительности при использовании select() или
WSAWaitForMultipleEvents(). Gregory использовал в качестве примера
многопоточную модель TCP-сервера, так что там вообще сложно говорить
о необходимости в высокой производительности непосредственно TCP.
* man 2 kqueue - Страница оперативной документации FreeBSD kqueue(2).
* http://people.freebsd.org/~jlemon/kqueue.pdf - "Kqueue: A generic and
scalable event notification facility", статья автора kqueue Джонотана Лемона.
* http://www.monkeys.com/freeware/kqueue-echo.c - пример использования kqueue
в реализации сервера echo.