Pull to refresh

Еще раз об архитектуре сетевых демонов

Reading time 13 min
Views 19K
Во многих статьях, в том числе на хабре, упоминаются и даже описываются разные способы построения архитектуры сетевых сервисов (демонов). При этом мало у кого из авторов есть реальный опыт создания и оптимизации демонов, работающих с десятками тысяч одновременных соединений и/или гигабитным трафиком.

Так как большинство авторов не удосуживается хотя бы залезть в документацию, то обычно в таких статьях вся информация базируется на неких слухах и пересказах слухов. Эти слухи бродят по сети и поражают википедию, хабрахабр и другие уважаемые ресурсы. В результате получаются опусы вроде "Вы наверное шутите, мистер Дал, или почему Node.js" (пунктуация автора сохранена): она, в основном, верная по сути, но изобилует неточностями, содержит ряд фактических ошибок и изображает предмет с какого-то непонятного ракурса.

Мне было сложно пройти мимо статьи, изобилующей фразами вроде «эффективные реализации polling'а на сегодняшний день имеются лишь в *nix-системах» (как будто poll() есть где-то, кроме некоторых *nix). Этот пост начинался как комментарий, разъясняющий уважаемому inikulin ошибки в его статье. В процессе написания оказалось, что проще изложить предмет с самого начала, что я собственно и делаю отдельным постом.
В моем очерке нет срыва покровов или каких-то неизвестных трюков, здесь просто описываются преимущества и недостатки разных подходов человеком, который проверял, как всё это работает на практике в разных операционных системах.
Для желающих просветиться — добро пожаловать под кат.

ТЗ для сетевого демона


Сначала нужно понять, что именно должны делать сетевые сервисы и в чем, вообще, состоит проблема.

Любой демон должен принимать и обрабатывать сетевые соединения. Так как стек протоколов TCP/IP вырос из UNIX, а эта ОС исповедует догму «всё есть файл», то сетевые соединения есть файлы особого типа, которые можно открывать, закрывать, читать и писать стандартными функциями ОС для работы с файлами. Обратите внимание на модальный глагол «можно», в совокупности со словом «теоретически» он очень точно описывает действительность.

Итак, первым делом любой демон вызывает системные функции socket(), затем bind(), затем listen() и в итоге получает файл специального типа «слушающий сокет». Параметры этих функций и дальнейшие действия демона очень сильно зависят от применяемого транспортного протокола (TCP, UDP, ICMP, RPD...), впрочем, в большинстве ОС вы можете bind-ить только первые два. В этой статье в качестве примера мы рассмотрим наиболее популярный протокол TCP.

Хотя слушающий сокет есть файл, всё, что с ним может происходить — это периодически возникающие события типа «запрос входящего соединения». Демон может принять такое соединение функцией accept(), которая создаст новый файл, на этот раз уже типа «открытый сетевой сокет TCP/IP». Предположительно, демон должен прочитать из этого соединения запрос, обработать его и отправить назад результат.

При этом, сетевой сокет — это уже более-менее нормальный файл: хоть он и был создан не самым стандартным образом, по крайней мере из него можно пытаться читать и писать данные. Но есть и существенные отличия от обычных файлов, расположенных на файловой системе:
* Все события происходят действительно асинхронно и с неизвестной длительностью по времени. Любая операция в худшем случае может занять десятки минут. Вообще любая.
* Соединения, в отличие от файлов, могут закрываться «сами собой» в любой, самый неожиданный момент.
* ОС не всегда сообщает о закрывшемся соединении, «мертвые» сокеты могут висеть по полчаса.
* Соединения на клиенте и на сервере закрываются в разное время. Если клиент попытается создать новое соединение и «доотправить» данные, возможно дублирование данных, а при неправильно написанном клиенте — и их потеря. Также возможно наличие на сервере нескольких открытых соединений от одного клиента.
* Данные расцениваются как поток байтов и могут буквально приходить порциями по 1 байту. Поэтому считать их, например, строками UTF-8 нельзя.
* Никаких буферов кроме тех, которые предоставил сам демон, в сети нет. Поэтому запись в сокет даже 1 байта может заблокировать демон на десятки минут (см. выше). Кроме того, память на сервере «не резиновая», демон должен уметь ограничивать скорость, с которой генерируются результаты.
* Любые ошибки могут случаться в любом месте, демон должен корректно обрабатывать их все.

Если написать цикл по всем открытым соединениям «в лоб», то первое же «подвисшее» соединение заблокирует все остальные. Да-да, на десятки минут. И тут возникают различные варианты организации взаимодействия различных модулей демона. Смотрите рисунок:

Disclaimer: На рисунке приведен не соответствующий реальности код на псевдоязыке. Многие важные системные вызовы и весь код обработки ошибок опущены для ясности.

2. Многопроцессная архитектура


Простейший способ не дать соединениям влиять друг на друга — это запустить для каждого из них отдельный процесс (то есть отдельную копию вашей программы). Недостатки этого метода очевидны — запуск отдельного процесса это очень ресурсоёмкая операция. Но в большинстве статей не объясняется, почему именно такой способ используется в том же Apache.

А всё дело в том, что именно процесс во всех ОС является единицей учета системных ресурсов — памяти, открытых файлов, прав доступа, квот и так далее. Если вы создаете демон удаленного доступа к операционной системе вроде Shell или FTP — вы просто обязаны запускать отдельный процесс от имени каждого залогинившегося пользователя, чтобы правильно учитывать права доступа к файлам. Аналогично, на сервере shared-хостинга одновременно, на одном «физическом» порту крутятся сотни сайтов разных пользователей — и процессы нужны apache для того, чтобы сайты одних пользователей хостинга не могли залезть в данные других пользователей. Использование процессов не очень-то влияет на производительность Апача:
image
На графике — количество обрабатываемых запросов к статическому файлу в секунду в зависимости от версии ядра Linux. Больше — лучше.
Тестовый стенд: Core i7 970, 3Gb DDR3, Nvidia GTX 460, 64GB OCZ Vertex SSD.
Источник: Phoronix.
Желаю и вашим демонам отдавать по 17к файлов в секудну.

Даже если пользователи вашего демона не зарегистрированы в операционной системе, но к вашему сервису выдвигаются повышенные требования по безопасности, выделение отдельного процесса под каждого пользователя есть очень разумное архитектурное решение. Это не позволит пользователям-«плохишам» получать или блокировать доступ к данным других пользователей, даже если они найдут баг в вашем демоне, позволяющий читать чужие данные или просто рушащий процесс демона.

Наконец, у каждого процесса имеется собственное адресное пространство, и разные процессы не мешают друг другу пользоваться памятью. Почему это преимущество — объясняется в части

3. Многопоточная архитектура


Потоки — это максимально легкие «процессы», имеющие общую память, системные ресурсы и права доступа, но разные стеки. Это означает, что у потоков общие динамические, глобальные и статические переменные, но разные локальные. В многопроцессорных и/или многоядерных системах разные потоки одного процесса могут выполняться физически одновременно.

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

Главным плюсом многопоточной архитектуры после производительности является последовательность и синхронность алгоритма обработки открытого соединения. Это значит, что алгоритм выглядит и выполняется именно так, как нарисовано на иллюстрации в первой части. Сначала из сокета читаются данные, столько времени, сколько для этого нужно, потом они обрабатываются — опять же, столько времени, сколько эта обработка потребует, потом результаты отправляются клиенту. При этом, если вы начнете отправлять результаты слишком быстро — поток автоматически заблокируется на функции write(). Алгоритм обработки прост и понятен хотя бы на самом верхнем уровне. Это очень, очень большой плюс.

Для относительно небольшого количества одновременных соединений многопоточная архитектура — отличный выбор. Но если соединений действительно много, скажем десять тысяч, переключение между потоками начинает занимать слишком много времени. Но даже это — не главный недостаток многопоточной архитектуры.

А главный состоит в том, что потоки не независимы и могут (и будут) друг друга блокировать. Чтобы понять, как это происходит, рассмотрим пример.

Допустим, нам нужно вычислить значение выражения
a = b + c;
, где a, b и с есть глобальные переменные.

В обычной, однопоточной ситуации компилятор сгенерирует примерно такой машинный код:
a = b; // MOV A, B
a += c; // ADD A, C


В многопоточном варианте использовать этот код нельзя. Другой поток может изменить значение b между первой и второй инструкциями, в результате чего мы получим неверное значение a. Если где-то в другом месте считается, что a всегда равно b+c, возникнет очень трудно воспроизводимая «плавающая» ошибка.

Поэтому, в многопоточном варианте используется код вроде такого:
lock a;
lock b;
lock c;
a = b;
a += c;
unlock c;
unlock b;
unlock a;

, где lock и unlock — это атомарные операции блокировки и разблокировки доступа к переменной. Они устроены таким образом, что если переменная уже заблокирована другим потоком, операция lock() будет ждать её освобождения.

Таким образом, если два потока начнут одновременно выполнять операции a = b + c и b = c + a, они заблокируют друг друга навсегда. Такая ситуация называется клинчем, поиск и разрешение клинчей — отдельная «больная тема» параллельного программирования. Но и без клинчей потоки, если они не снимают блокировки быстро, могут останавливать друг друга на достаточно длительные промежутки времени.

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

Но, казалось бы, поскольку соединения в демонах почти независимы, откуда у них могут взяться общие переменные?
А вот откуда:
* Общая очередь новых соединений;
* Общая очередь доступа к базе данных или подобным ресурсам;
* Общая очередь запросов на выделение памяти (да-да, malloc() и new() могут вызвать блокировку);
* Общий журнал (log-файл) и общие объекты подсчета статистики.
Это только самые очевидные.

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

4. Неблокирующая архитектура


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

Неблокирующий ввод-вывод — это просто режим доступа к файлу, который можно установить в большинстве современных ОС. Если в обычном, «блокирующем» режиме функция read читает из файла столько байт, сколько заказал программист, а пока это чтение происходит — «усыпляет» вызвавший её поток, то в неблокирующем режиме та же самая функция read читает не из файла, а из его кэша, столько байт, сколько в этом кэше есть, и после этого сразу возвращается, никаких потоков не усыпляя и не блокируя. Если кэш был пуст, неблокирующий read() прочтет 0 байт, установит код системной ошибки в значение EWOULDBLOCK и сразу вернется. Но всё равно это обычный синхронный вызов обычной синхронной функции.

Некоторая путаница, в частности, в англоязычной википедии, в которой неблокирующий синхронный ввод-вывод назван «асинхронным», вызвана, видимо, не очень любознательными апологетами ОС Linux. В этой операционной системе достаточно долго, вплоть до ядер 2.6.22-2.6.29, просто не было никаких асинхронных функций ввода-вывода вообще (да и сейчас есть не весь необходимый набор, в частности нет асинхронной fnctl), и некоторые программисты, писавшие только под эту ОС, ошибочно называли неблокирующие синхронные функции «асинхронными», что прослеживается в ряде старых мануалов для Linux.

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

В реальных условиях 95% вызовов неблокирующего read() будут читать по 0 байт. Чтобы избежать этих «холостых» вызовов ядра ОС, существует функция select(), позволяющая попросить операционную систему выбрать из вашего списка соединений те, из которых уже можно читать и/или писать. В некоторых *nix ОС есть вариант этой функции под названием poll().
Подробнее касательно poll(): эта функция появилась как требование очередной версии стандарта POSIX. В случае Linux poll() сначала был реализован как функция стандартной библиотеки языка Си (libc>=5.4.28) в виде обертки поверх обычного select(), и только через некоторое время «переехал» в ядро. В Windows, например, нормальной функции poll() нет до сих пор, но начиная с Vista есть некий паллиатив для упрощения миграции приложений, так же реализованный в виде обертки вокруг select() на Си.
Не могу не поделиться графиком, показывающим, к чему все эти нововведения приводят. На графике — время прокачки 10 Гб данных через интерфейс-петлю в зависимости от версии ядра. Меньше — лучше. Источник тот же, тестовый стенд тот же.
image



В любом случае, хотя у select() есть определенные ограничения (в частности, на количество файлов в одном запросе), использование этой функции и неблокирующего режима ввод-вывода — это способ в три десятка строк кода переложить всю работу на операционную систему и просто обрабатывать свои данные. В большинстве случаев, отвечающий за неблокирующий ввод-вывод поток будет потреблять всего пару процентов вычислительной мощности одного ядра. Выполняющие все вычисления внутренние потоки ядра операционной системы «съедят» в десятки раз больше.

Вернёмся к уменьшению количества потоков.
Итак, в демоне имеется большое количество объектов класса «соединение», и существует набор операций, которые нужно применить к каждому объекту-соединению, причем в нужном порядке.

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

В архитектуре с неблокирующим вводом-выводом создается поток под каждую операцию, которая последовательно применяется к разным объектам. Это немножко похоже на SIMD-инструкции типа MMX и SSE: одна инструкция применяется сразу к нескольким объектам. Чтобы выдерживать необходимую последовательность операций (то есть сначала вычислить результат, а потом его отправлять), в общей памяти процесса создаются очереди заданий между потоками. Обычно очереди создаются на базе кольцевых буферов, которые в данном случае можно реализовать «неблокирующим» образом.

В реальном сетевом сервисе между чтением запроса и отправкой результата будет стоять достаточно сложный разветвленный алгоритм обработки, возможно включающий вызов сервера приложений, СУБД или другие «тяжелые» операции, а также полный набор ветвлений, циклов, обработку ошибок на каждом шаге и т.п. Разбить всё это по заранее неизвестному количеству выполняющихся одновременно потоков, да еще так, чтобы нагрузка на процессорные ядра была примерно одинаковой — это высший уровень умений разработчика, требующий виртуозного владения всеми аспектами системного программирования. В большинстве случаев делают намного проще: заключают в отдельный поток всё, что находится между read() и write(), и запускают N=количество ядер копий этого потока. А затем изобретают костыли для входящих в клинч, конкурирующих за ресурсы, «убивающих» СУБД и т.д. параллельных потоков.

5. Асинхронный ввод-вывод


Если вы не понимаете отличие асинхронных функций от синхронных, то для простоты можете считать, что асинхронная функция выполняется параллельно и одновременно с вызвавшей её программой, например, на соседнем ядре. С одной стороны, вызвавшей программе не нужно ждать окончания вычислений и она может сделать что-то полезное. С другой стороны, когда результаты асинхронной функции готовы, нужно каким-то образом сообщить об этом программе-«заказчику». То, каким образом происходит это сообщение, реализовано в разных ОС очень по-разному.

Исторически одной из первых ОС с поддержкой асинхронного ввода-вывода стала Windows 2000.
Типичный use case был такой: однопоточное приложение (никаких многоядерных процессоров тогда не было) загружает, например, большой файл в течение десятков секунд. Вместо зависания интерфейса и «часиков», которые наблюдались бы при синхронном вызове read(), в асинхронном варианте основной поток программы не «зависнет», можно будет сделать красивый прогресс-бар, отображающий процесс загрузки, и кнопку «отмена».
Для реализации «прогресс-бара» в асинхронные функции ввода-вывода Windows передается специальная структура OVERLAPPED, в которой ОС отмечает текущее количество переданных байт. Программист может читать содержимое этой структуры в любой удобный ему момент — в основном цикле обработки сообщений, по таймеру и т.д. В этой же структуре в конце операции будет записан её итоговый результат (общее число переданных байт, код ошибки если он есть и т.п.).

Кроме этой структуры, в асинхронные функции ввода-вывода можно передать собственные функции обратного вызова (callback), принимающие указатель на OVERLAPPED, которые будет вызваны операционной системой по окончании операции.
Настоящий, честный асинхронный запуск callback-функции, через прерывание программы в любом месте, где бы она не находилась, не отличим от запуска второго потока выполнения программы на этом же ядре. Соответственно, нужно либо очень аккуратно писать callback-функции, либо применять все «многопоточные» правила, касающиеся блокировок доступа к общим данным, что, согласитесь, очень странно в однопоточном приложении. Чтобы избежать потенциальных ошибок в однопоточных приложениях, Windows ставит необработанные callback-и в очередь, а программист должен явно указать места в своей программе, где она может быть прервана для выполнения этих callback-ов (семейство функций WaitFor*Object).

Описанная выше схема асинхронного ввода-вывода является «родной» для ядра WindowsNT, то есть все остальные операции так или иначе реализуются через неё. Полное название — IOCP (Input/Output Completion Port). Считается, что эта схема позволяет добиться от железа теоретически максимальной производительности. Любые демоны, рассчитанные на серьезную работу под Windows, должны разрабатываться именно на основе IOCP. Подробнее см. введение в IOCP в MSDN.

В Linux вместо нормальной структуры OVERLAPPED есть некое слабое подобие aiocb, позволяющая определить только факт завершения операции, но не её текущий прогресс. Вместо определяемых пользователем callback-ов ядро использует сигналы UNIX (да-да, те, которые kill ). Сигналы приходят полностью асинхронно, со всеми вытекающими, но если вы не чувствуете себя гуру в деле написания реентерабельных функций, вы можете создать файл специального типа (signalfd) и читать из него информацию о пришедших сигналах обычными синхронными функциями ввода-вывода, в том числе неблокирующими. Подробнее см. man aio.h.

Использование асинхронного ввода-вывода не накладывает никаких ограничений на архитектуру демона, теоретически она может быть любой. Но, как правило, используются несколько рабочих потоков (по количеству процессорных ядер), между которыми равномерно распределяются обслуживаемые соединения. Для каждого соединения строится и программируется конечный автомат (Finite State Machine, FSM), появление событий (вызовов callback-функций и/или ошибок) переводит этот автомат из одного состояния в другое.

Резюме


Как мы видим, у каждого способа есть свои преимущества, недостатки и области применения. Если вам нужна безопасность — используйте процессы, если важна скорость работы при большой нагрузке — неблокирующий ввод-вывод, а если важна скорость разработки и понятность кода, то подойдет многопоточная архитектура. Асинхронный ввод-вывод — основной способ в Windows. В любом случае не стоит пытаться писать код для работы с вводом-выводом самостоятельно. В сети есть свободные готовые библиотеки для всех архитектур и операционных систем, вылизанные за десятки лет почти до блеска. Почти — потому что в вашем случае всё равно придется что-то подкручивать, подпиливать и донастраивать под ваши условия. Интернет — сложная штука, здесь не бывает универсальных решений на все случаи жизни.

Как бы то ни было, одним вводом-выводом демоны не обходятся, а во время обработки запросов случаются намного более сложные «затыки». Но это уже тема для другой статьи, если оно кому-либо интересно.

Ссылки


1. The C10K problem (англ), спасибо за наводку o_O_Tync
2. Хелп к библиотеке libev, вкусная часть с описанием различных механизмов массового ввода-вывода (англ), за наводку спасибо saterenko
3. FAQ эхи ru.unix.prog
4. Введение в unix-библиотеку libaio (англ.)
5. Тестирование производительности ядер с 2.6.12 по 2.6.37 (англ).
Tags:
Hubs:
+157
Comments 53
Comments Comments 53

Articles