shocker: (Default)
[personal profile] shocker
Хочу осветить основные архитектуры применяемые при проектировании параллельных серверов (в контексте распределенных вычислений). Будем считать что это HTTP-сервер, как частный случай (протокол не имеет значения).

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

Вот 4 основных подхода:
- Многопоточный сервер;
- Многопроцессный на fork`ах;
- Многопроцессный на pre-fork`ах;
- Сервер с мультиплексингом ввода/вывода.


Теперь подробнее и с частными случаями. Под Unix будем понимать Posix-совместимую операционную систему Linux, вдаваться в специфику различных Unix-подобных систем не будем (например, epoll интерфейс специфичен для ядра Linux начиная с версии 2.5.66, во FreeBSD аналогом является kqueue).


Многопоточный сервер обещает простоту программирования и высокую производительность за счет отсутствия межпроцессного взаимодействия (zero copy). По умолчанию так работает Apache 2.

На каждого клиента (сокет) выделяется отдельный поток исполнения (порождается новый или используется уже существующий). Единственный недостаток этого подхода - отсутствие изоляции адресного пространства потока. В результате снижается безопасность и надежность сервера. Вы можете допустить ошибки или что-то упустить из виду оставив дыру в безопасности. Теоретически один клиент может прочитать или модифицировать данные предназначенные для другого клиента, а ошибка в программе будет приводить к отказу всего сервера полностью (Segmentation fault и падает все).

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

Многопроцессный сервер на fork`ах самый простой, надежный и старый вариант на Unix системах. Суперсервер inetd именно так и работает.

Основной процесс в цикле ожидает новых соединений на системном вызове accept (если сокетов более одного обычно применяют select, poll, epoll). После получения очередного клиентского сокета основной процесс форкается (fork) в результате чего файловый дескриптор  наследуется (копируется в таблицу дескрипторов дочернего процесса с увеличением счетчика ссылок). Затем родительский процесс закрывает (close) свою копию дескриптора и возвращается к ожиданию нового соединения. Дочерний процесс продолжает обслуживание клиента в отдельном изолированном потоке исполнения.

Обычно после этого он замещает стандартный ввод и вывод новым дескриптором (dup2) и заменяет содержимое страниц памяти содержимым другого исполняемого файла (грубо говоря запускает другую программу но в том же процессе) с использованием системных вызовов семейства exec (например, execlp). Запущенная программа теперь и не подозревает что на stdin/stdout у нее удаленный клиент. И главное все это займет строк 50 кода, очень удобно, под Windows этого не добиться. Единственная проблема - низкая эффективность при частых коннектах, fork очень дорогая операция.

Многопроцессный сервер на pre-fork`ах объединяет достоинства двух предыдущих способов. Так работает легендарный Apache 1.3. При данном подходе не происходит постоянных fork`ов, это делается один раз при старте (хотя количество дочерних процессов может меняться при изменении характера нагрузки).

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

Итак, как же это работает? Все очень просто... родительский процесс в самом начале создает один или несколько серверных сокетов (те, для которых сделали listen) и расфоркивается (fork) столько раз, сколько рабочих процессов в пуле мы хотим иметь. Далее он может ничего не делать, а тупо ждать завершения работы дочерних процессов (при этом рестартовать их) и сигналов снаружи. Дочерние процессы все вместе в цикле висят ожидая входящего соединения на серверном сокете (accept). Как приходит новый клиент только один процесс разблокируется и получит клиентский сокет, остальные будут продолжать ждать. Ну вот и все :)

Рекомендуется использовать семафор (semget) для ограничения одновременного доступа к серверному сокету, это сильно поможет. В наборе семафоров только один семафор с начальным значением 1. Дочерние процессы сначала пытаются захватить семафор (уменьшить на единицу), а только после этого делают accept на сокете. Сразу после accept семафор освобождают (увеличивают на единицу). При этом лучше иметь неблокируемый сокет (O_NONBLOCK) и операции над семафором делать с флагом SEM_UNDO.

Как развитие идеи могу предложить подумать над pre-fork сервером с несколькими группами воркеров (дочерних процессов) имеющими разные приоритеты при приеме очередного соединения. Например, за сервером мы имеем базу данных и не хотим напрягать ее большим количеством сессий или частыми реконнектами со стороны клиентов (скажем CGI-скриптов). Тогда этот вариант для нас... если воркер (их будет несколько) уже имеет открытую сессию с БД он имеет более высокий приоритет перед воркерами без соединений с базой. Соответственно если свободен хоть один из процессов с приоритетом именно он получит нового клиента.
Если воркер долго не получает новых запросов, он может закрыть соединение с БД по таймауту и потерять приоритет. Если все приоритетные воркеры заняты, вступают в дело остальные (и получают приоритет).

Это можно легко реализовать на потоках, и на pre-fork с применением управляющего процесса, разделяемой памяти и кучи семафоров для выбора подходящего дочернего процесса (по одному на потомка + два для реализации блокировки чтения/записи разделяемой памяти). Во втором случае будут проблемы с максимальным количеством потомков т.к. по умолчанию кол-во семафоров в наборе сильно ограничено (примерно в районе 200 штук) и его надо специально увеличивать.

Еще можно использовать сложную математику семафоров System V (semget, semop), в результате можно доверить выбор воркера операционной системе, снизить оверхед и количество семафоров.

Для этого достаточно 4-х семафоров в наборе:
- сигнальный (родительский процесс выставляет его когда видит новый запрос на соединение, изначально равен 0);
- количество свободных процессов в группе с приоритетом (изначально равен 0);
- количество свободных процессов в группе без приоритета (изначально равен кол-ву дочерних процессов);
- семафор для ограничения множественного доступа с серверному сокету (изначально равен 1);

Все дочерние процессы в пуле пытаются унарно уменьшить 1-й, 3-й и 4-й семафоры на один и ждать нуля на втором семафоре (т.е. момента когда не останется ни одного свободного процесса в группе с приоритетом). При этом изначально значение 3-го семафора равно количеству дочерних процессов.
Родительский процесс наблюдает за серверным сокетом при помощи, например select`а. Как появляется новый запрос, родительский процесс увеличивает значение первого семафора на 2 и начинает ждать на нем нуля (ждет пока кто-нибудь не заберет клиента).
Один из дочерних процессов разблокируется, вызывает accept, затем уменьшает 1-й семафор на один (разблокирует родителя, главное не упасть до этого... все сигналы блокируем!) и увеличивает 4-й на один (освобождает сокет).
Родитель разблокируется и продолжает ждать новых клиентов.
Если дочерний процесс смог открыть сессию с БД, то по завершению обработки он увеличивает 2-й семафор на один (переходит в группу с приоритетом), если нет, то 3-й (остается в группе без приоритета).
Если он получил приоритет, то теперь пытается уменьшать на единицу 1-й, 2-й и 4-й семафоры не ожидая нуля как раньше.
Если процесс закрывает соединение с БД , то должен уменьшить 2-й и увеличить 3-й семафоры (перейти в группу без приоритета).

Что же делать если кончатся все свободные процессы? Нужен еще один особенный процесс... он пытается уменьшить на единицу 1-й и 4-й семафор, при этом ждет нуля на 2-м и 3-м (момент когда кончатся все свободные воркеры).
В результате он разблокируется если больше некому, пошлет клиента подальше, уменьшит на единицу 1-й семафор, увеличит на один 4-й и вернется к своим делам. Как вариант, он может предпринять меры для расширения пула.

Вот и все, надеюсь вы поняли :)

Реально тут возможны зависания (например, если дочерний процесс упадет в процессе ожидания родителем подтверждения), так что придется подумать (пригодиться флаг SEM_UNDO и/или обработка SIGCHLD в родительском процессе). И обязательно блокировать сигналы в критичных местах.

Единственная проблема - при таком подходе мы не сможем различать процессы внутри группы и скорее всего они станут перебираться последовательно (зависит от реализации ОС). В результате процесс с приоритетом скорее всего таким и останется т.к. периодически будет получать нового клиента.

Еще одна тема для размышления - передача файлового дескриптора между процессами через локальный сокет!
Эта экзотика появилась очень давно и входит в стандарт Posix.
Механизм похож на тот, что в Winsock2 (там передать можно только сокет).
Вдаваться в подробности не буду, есть инфа в инете. Это реально работает и может найти применение, правда я не сторонник таких гвоздиков :)

Ключевые слова - PF_UNIX, SCM_RIGHTS, CMSG_DATA, CMSG_FIRSTHDR, msghdr, sendmsg.


Ну и на последок самое вкусное - сервер с мультиплексингом ввода/вывода. Наиболее известные представители: nginx, lighttpd, memcached (не HTTP, но яркий представитель, использует библиотеку libevent).

До этого мы рассматривали синхронный (блокирующий) ввод/вывод, теперь перейдем к асинхронному.

Можно использовать опросную модель, но это для школьников... есть AIO интерфейс, но в Linux он куцый... можно использовать специфический механизм STREAMS (O_ASYNC), но это не очень переносимо и скорее всего реализуется через нити (threads).

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

Но есть более подходящий способ - мультиплексинг ввода/вывода.

Для начала переводим файловый дескриптор в неблокирующий режим (O_NONBLOCK). После этого, все операции ввода/вывода будут завершаться ошибкой EAGAIN в момент, когда при нормальных условиях произошло бы блокирование (расширенная обработка ошибок очень важна, например, EINTR возникает при прерываниях, реально это не ошибка - просто надо повторить вызов).

Существуют системные вызовы для мониторинга состояния множества файловых дескрипторов за раз, вот основные из них: select, poll, kqueue (O(1), FreeBSD) и epoll (O(1), Linux). Реально лучше воспользоваться библиотекой libevent, в зависимости от ОС она будет использовать самый подходящий интерфейс низкого уровня. Кроме того, она уже реализует механизм таймеров, а это очень важно (но насчет O(1) для таймеров не уверен, очень уж она универсальна, 100% где-то там бинарные деревья иди hash-таблицы).

В такой моделе мы можем в одном процессе (потоке) обслуживать множество клиентов одновременно.
Реально это иллюзия, такая же как и многозадачность со своими потоками :). Зато мы можем оптимизировать процесс как нам нравится, а не как нравится тупой ОС.
При этом необходимо где-то хранить промежуточное состояние для каждого клиента (контекст). Такая программа является конечным автоматом (FSM). Она гораздо сложнее предыдущих вариантов. При программировании надо быть очень внимательным т.к. каждая мелочь важна и может стать причиной взлома, отказа в обслуживании или неверной работы.


Заключение.

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

Не забываем о TCP_NODELAY и значении backlog при вызове listen!
И самое главное, если пишем на C++ с использованием STL - всегда ловим исключения. Особенно если это FSM или многопоточный сервер.

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


Моя реализация на C++ с STL и libevent (есть что оптимизировать) на одном ядре Core 2 Duo E8400 (3.0 Ghz) выдала 33000 запросов в секунду по сети (уперся в процессор). На другой машине работал apache benchmark, 200 активных Keep-Alive клиентов.
Сервер только читал и парсил HTTP -запросы, считал CRC32 по заголовкам, подготавливал и отдавал обратно фиксированный ответ. Без Keep-Alive получилось 9000, процессор отдыхал. При задействовании очереди и Keep-Alive клиентах получилось 15000 на 2-х локальных воркерах (увеличение ничего не дает, это предел). На Xeon 3.6 получил всего 10000! Скорее всего причина во включенном HT.
Мой собственный pre-fork сервер, при тех же условиях и ничего полезного не делая выдал 37000 запросов в секунду, но на 2-х ядрах (против 33000 на одном). Результат достойный, но не надо забывать что это только 200 клиентов (кстати для них пришлось поднять 200 процессов против одного в первом случае). Без Keep-Alive получил те же 9000.
Полагаю в жестких условиях интернета для сохранения адекватности pre-fork серверу придется очень сильно распухнуть.


Рекомендую почитать толстую зеленую книгу о сетевом программировании (название не помню), хотя последнее издание вроде уже синее.


Я много лет занимался этими вопросами, если Вы спросите меня что я об этом всем думаю, то я отвечу: Лучше взять готовое решение и не смешить людей. Их сейчас великое множество на любой вкус в т.ч. и свободных. Как сказал Антон Самохвалов (Яндекс), идеальный Web-сервер общего назначения уже придуман - это Apache 2 (многопоточный вариант). Берем фрю, Apache 2, ставим перед ним реверсный прокси nginx (он же будет делить нагрузку), а логику реализуем на C/C++ в модулях  апача либо на PHP (mod_php) либо на Perl (mod_perl) либо еще на чем-нить, главное что бы CGI-я и рядом не было. Там же ставим MySQL для персиста и memcached на всякий случай. Мускулев может быть много, а в memcached будет в том числе содержаться маршрутная информация.

Если пойти дальше, то HP-UX или MS Windows, IBM MQSeries и WebSphere, база любая (обойдемся без хранимых процедур и тригеров), само собой nginx на переднем крае :)
А может вообще остановиться целиком на продуктах от Microsoft.


На этом все, удачи :)

Date: 2009-04-01 11:40 (UTC)
From: [identity profile] clark15b.livejournal.com
Да, и спасибо за положительные отзывы! Это для меня очень важно.

Profile

shocker: (Default)
shocker

December 2019

S M T W T F S
1234567
891011121314
15161718 192021
22232425262728
293031    

Most Popular Tags

Style Credit

Expand Cut Tags

No cut tags
Page generated 24 Dec 2025 13:08
Powered by Dreamwidth Studios