Rambler's Top100 Service калинин.ru / программирование / сети /  << 16.07.01 >>

События ядра в FreeBSD.

Обработка большого количества сетевых соединений всегда затруднительна. Мало того, не существует стандартных решений, подходящих для проблем любого вида, в которых возникает большое количество соединений. В этой статье пойдет речь о новом программном интерфейсе, появившемся в 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() (передается больше параметров).

Обычно организуется бесконечный цикл вида:

fd_set rfds, wfds;
struct timeval tv;
tv.tv_sec = 0; tv.tv_usec = 500;

for( ; ; )
{
   FD_ZERO(&rfds); 
   FD_ZERO(&wfds);

   int max_fd = -1;

Необходимо поместить в 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;


...

EV_SET(kq_change_list + kq_chlist_used, fd, EVFILT_READ, EV_ADD, 0, 0, 0);
kq_chlist_used++;
...

n = kevent(kq, kq_change_list, kq_chlist, kq_events, NEVENTS, &tv);

if(n <= 0) { /* Ошибка */ }

for(i = 0; i < n; i++)
{
   if(kq_events[i].flags & EV_ERROR) { /* Ошибка */ }

   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 год, но интересны не абсолютные, а относительные единицы измерения):

Web-сервер Модель Размер Запросы в секунду Посетители
Название Версия Дата Строки Исполняемый файл Маленькие файлы CGI Большие файлы
thttpd 2.03 11jul98 select 7,229 49,584 720 100 1000+
Apache 1.3.0 05jun98 pre-fork 73,381 397,152 250 90 150
mathopd 1.2b9 23may98 select 3,658 49,088 770 75 500
Roxen 1.2.29 10jun98 threads 247,789 502,284 170 11 50
Boa 0.92 23dec96 select 4,103 75,788 475 115 50
Jigsaw 2.0beta1 08apr98 Java threads 95,744 1,841,601 45 n/a 25
Acme.Serve - 18may98 Java threads 4,718 79,943 45 n/a 25
CERN 3.0A 15jul96 fork 56,028 561,696 115 65 300
NCSA 1.5.2a-export 12oct96 pre-fork 23,726 196,040 260 70 350
Netscape FastTrack 3.01-export-us 02oct97 threads n/a 1,896,016 - - -
Netscape Enterprise 3.5.1-export-us 02feb98 threads n/a 1,977,568 - - -
Zeus 3.1.4 10jul98 select n/a 1,367,976 1050 70 500

Полную версию этой таблицы можно найти здесь. Еще раз напоминаю, что данные на 1998 год и, следовательно, thttpd тестировался без kqueue. Кроме того, все подобные тесты проводятся "в вакууме", то есть с одного-двух ip-адресов в локальной сети и, следовательно, нагрузки на реализацию стека протоколов TCP/IP не такие большие, как в реальной жизни. Таким образом, в 1998 реальные цифры были меньше, чем приведенные в таблице и отличаться они могли в несколько раз при росте количества одновременных соединений (для маленького количества соединений цифры отличались бы не сильно).

Еще одно интересное место в этой таблице, на которое хочется обратить внимание --- производительность web-серверов, написанных на Java. Это место я оставлю без комментариев.

На мой взгляд, для сервера раздачи картинок лучше всего подходят:

  • thttpd, так как в нем есть поддержка kqueue изначально.
  • mathpod, потому что в нем есть нормальная реализация HTTP keep-alive. В случае замены select() на kqueue становится более предпочтительным, чем thttpd. Кроме того, он проще чем thttpd (в два раза меньше строчек исходного кода), что тоже является несомненным плюсом. Если из него изъять кучу ненужных возможностей, таких как поддержка виртуальных серверов, то лучше варианта не найти.

Резюме

Реальное применение kqueue на загруженном веб-сервере помогло увеличить его производительность как минимум в два раза (учитывая то, что он не только отдает картинки, но и предварительно их обсчитывает). Точное увеличение при замене его на сервере, отдающего уже сжатые картинки, неизвестно, потому что даже в локальной сети не удалось нагрузить компьютер на все 100%. Так что --- рекомендую.

PS

В свое время Gregory Liokumovich рассказывал о событийной модели в WinSock, которая очень похожа, как я себе это понимаю, на kqueue. С другой стороны, я не знаком с внутренним устройством Windows так, чтобы утверждать насколько велика разница в производительности при использовании select() или WSAWaitForMultipleEvents(). Gregory использовал в качестве примера многопоточную модель TCP-сервера, так что там вообще сложно говорить о необходимости в высокой производительности непосредственно TCP.


Версия для печати


  Ссылки по теме:
kqueue(2)
   Страница оперативной документации FreeBSD kqueue(2).
http://people.freebsd.org/~jle
   "Kqueue: A generic andscalable event notification facility", статья автора kqueue Джонотана Лемона.
http://www.monkeys.com/freewar
   пример использования kqueue в реализации сервера echo.
  Рядом в разделе:
Обзор CORBA (14.01.01)
   CORBA (расшифровывается как Common Object Request Broker) это технология, которая позволяет рассматривать компоненты распределенной системы как объекты, отвечающие некоторым определенным интерфейсам....   >>>>
"Тонкий" клиент (19.12.00)
   В предыдущей заметке Gregory Liokumovich рассказывал о применении событийной модели WinSock для программирования сетевых приложений. На самом деле, как мне кажется,...   >>>>
  Рядом по дате:
Новые технологии в рекламе (29.07.01)
   Как хорошо, что основной операционной системой, которой я пользуюсь, является малораспространенная среди российских интернетошатающихся FreeBSD. Как хорошо, что браузеры, которые я...   >>>>
Круглая дата (13.07.01)
   Тихо-тихо и незаметно прошла историческая дата: 11 июля 2000 года я написал первую заметку и выложил ее на свою домашнюю страничку....   >>>>
  Содержание:
Заглавная страница
Мой блог
Мое резюме
Дайджест
Программирование
   C&C++
Сети
Unix
Алгоритмы
Оптимизация
Соревнования
Отвлеченно
XML
TeX
Просто так
Студенческое
Туризм
  Байки
Фотографии
Комментарии
   Книги
Web-ресурсы
Фильмы
Интернет
Программное обеспечение
Жизнь
Благодарности
Форум
Хронология
 
  В этом разделе:
События ядра в FreeBSD. (16.07.01)
   Обработка большого количества сетевых соединений всегда затруднительна. Мало того, не существует стандартных решений, подходящих для проблем любого вида, в которых возникает...   >>>>
Обзор CORBA (14.01.01)
   CORBA (расшифровывается как Common Object Request Broker) это технология, которая позволяет рассматривать компоненты распределенной системы как объекты, отвечающие некоторым определенным интерфейсам....   >>>>
"Тонкий" клиент (19.12.00)
   В предыдущей заметке Gregory Liokumovich рассказывал о применении событийной модели WinSock для программирования сетевых приложений. На самом деле, как мне кажется,...   >>>>
Событийная модель в WinSock (12.12.00)
   Автором этого текста является Gregory Liokumovich ( ). Он любезно прислал мне этот текст, связанный с программированием сетей при помощи интерфейса...   >>>>
Неблокирующий connect() (01.12.00)
   В продолжение темы о замене блокирующего вызова , хочется рассказать о другой функции интерфейса сокетов, . Она имеет следующий прототип: int...   >>>>
Определение ip-адреса по имени хоста, adns (05.11.00)
   Есть такой, характерный для организации "традиционного" UNIX'а, системный вызов под названием : struct hostent * gethostbyname(const char *name); Традиционен он тем,...   >>>>
lingering close (29.10.00)
   Когда программа выкачивает один файл с удаленного сервера с использованием протокола TCP, а после этого сразу же "отваливается", то проблем, скорее...   >>>>
Содержание раздела полностью...
   Примерно в тоже время
Новые технологии в рекламе (29.07.01)
   Как хорошо, что основной операционной системой, которой я пользуюсь, является малораспространенная среди российских интернетошатающихся FreeBSD. Как хорошо, что браузеры, которые я...   >>>>
Круглая дата (13.07.01)
   Тихо-тихо и незаметно прошла историческая дата: 11 июля 2000 года я написал первую заметку и выложил ее на свою домашнюю страничку....   >>>>
Хронология полностью...
   Содержание
Заглавная страница
Мой блог
Мое резюме
Дайджест
Программирование
  C&C++
Сети
Unix
Алгоритмы
Оптимизация
Соревнования
Отвлеченно
XML
TeX
Туризм
  Байки
Фотографии
Комментарии
  Книги
Web-ресурсы
Фильмы
Интернет
Программное обеспечение
Жизнь
Студенческое
Просто так
Благодарности
Форум
Хронология
© 2000-2008, Andrey L. Kalinin
mailto:andrey@kalinin.ru
Rambler's Top100