Пользователь
0,4
рейтинг
17 ноября 2010 в 05:50

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

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

Так как большинство авторов не удосуживается хотя бы залезть в документацию, то обычно в таких статьях вся информация базируется на неких слухах и пересказах слухов. Эти слухи бродят по сети и поражают википедию, хабрахабр и другие уважаемые ресурсы. В результате получаются опусы вроде "Вы наверное шутите, мистер Дал, или почему 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 (англ).
Копетан О. Я. @vanxant
карма
109,5
рейтинг 0,4
Реклама помогает поддерживать и развивать наши сервисы

Подробнее
Спецпроект

Самое читаемое Разработка

Комментарии (53)

  • +10
    У меня диссонанс из-за различий в вашем нике и содержимым статьи )
    За то и люблю хабр, что в ответ на одну крепкую статью запросто может прилететь еще более крепкая, с разоблачениями и уточнениями. Кондитеры ликуют!
  • +5
    Но это уже тема для другой статьи, если оно кому-либо интересно.


    Об чём речь? Конечно интересно. И спасибо за статью, с удовольствием прочел.
  • +4
    Нельзя не упомянуть The c10k problem: этот документ до сих пор очень актуален.
    • 0
      Да, очень хорошая статья. В принципе, по содержанию очень похожа на мою, с упором в разные версии unix.
  • +1
    Спасибо, очень познавательно! Жду следующую статью!
  • 0
    Как бы то ни было, одним вводом-выводом демоны не обходятся, а во время обработки запросов случаются намного более сложные «затыки».
    Есть способ «миксовать» используя неблокирующий ввод-вывод для сети и мелких операций а «затыки» кидать в потоки.
  • 0
    Да и я очень жду следующую статью, Вас приятно читать!
  • +6
    Спасибо за адекват посреди всего этого шума вокруг Node.js.
  • 0
    Ещё всем интересующимся можно посоветовать почитать книги Стивенса и Снейдера
  • 0
    Торт! :)

    Спасибо!
  • +1
    Что-то про асинхронный ввод/вывод вы высказались не очень хорошо. У начинающих может сложится впечатление, что это сложно и не надо. В своих демонах весь сетевой ввод/вывод осуществляю через libev (раньше использовал libevent), ничего сложного в этом нет.
    • 0
      Скажите, вы вручную включаете EVFLAG_SIGNALFD? Или пользуетесь дефолтными epoll/kqueue/whatever?
      • 0
        У меня как-то исторически сложилось так, что пользуюсь сигналами через sigaction и только для отлавливания SIGUSR1, SIGUSR2, SIGHUP, SIGINT и SIGTERM. В ранних версиях libevent пробовал использовать их реализацию обработки сигналов, но демон стал падать в сегфолт, вернулся к обратно :)
  • 0
    Все эти тесты и высказывание по производительности на nix и win системах ничего не доказывают без детального описания процесса тестирования и непосредственно самой программы. В тестах особо нет нагрузки и нет задач которые требуют относительно длительной обработки данных. И всё упирается в пределы железа и конкретной ОС, а на деле очень часто всё может быть совсем наоборот.

    Ну если уже и делать сетевой демон под никсы, то использовать все возможности системы, а не только что-то одно. Если протокол общения работает по схеме: Клиент->Сервер->Клиент (что чаще всего бывает) то для начала желательно было бы применить Accept Filter, чтобы не обрабатывать лишние коннекты. А потом завязать всё систему на epoll(*nix) или kqueue (FreeBSD). Ну а далее обработка запросов через пул потоков. И даёт очень хорошие результаты. Но при этом практически во всех статьях и книгах по программированию про это ни слова, а больше всего делается упор на select и poll. А потом все и считают что select и poll — это самое классное что есть и пытаются впихнуть его куда надо и куда не надо.

    Кстати, для Windows с помощью IOCP можно сделать не чуть не хуже по производительности приложения, вот тока более сложнее. Хотя на тестах почему-то при 300к TCP коннектах у Windows (8 ГБ ОЗУ + 8 ядер проц) случился крах TCP. Т.е. PING проходил, на UDP пакеты DNS сервер отвечал, но все TCP коннекты отваливались. Помог только ребут сервера.
    • +3
      Скажите, а вы пробовали epoll() или kqueue() в реальных проектах?
      Я там ссылки не зря приводил, они какбе полезны. Вот описание epoll и kqueue от разработчиков libev.
      Избранные места:
      EPOLL
      For few fds, this backend is a bit little slower than poll and select, but it scales phenomenally better. While poll and select usually scale like O(total_fds) where n is the total number of fds (or the highest fd), epoll scales either O(1) or O(active_fds).

      The epoll mechanism deserves honorable mention as the most misdesigned of the more advanced event mechanisms: mere annoyances include silently dropping file descriptors, requiring a system call per change per file descriptor (and unnecessary guessing of parameters), problems with dup, returning before the timeout value, resulting in additional iterations (and only giving 5ms accuracy while select on the same platform gives 0.1ms) and so on. The biggest issue is fork races, however — if a program forks then both parent and child process have to recreate the epoll set, which can take considerable time (one syscall per file descriptor) and is of course hard to detect.

      Epoll is also notoriously buggy — embedding epoll fds should work, but of course doesn't, and epoll just loves to report events for totally different file descriptors (even already closed ones, so one cannot even remove them from the set) than registered in the set (especially on SMP systems). Libev tries to counter these spurious notifications by employing an additional generation counter and comparing that against the events to filter out spurious ones, recreating the set when required. Last not least, it also refuses to work with some file descriptors which work perfectly fine with select (files, many character devices...).

      Epoll is truly the train wreck analog among event poll mechanisms.

      While nominally embeddable in other event loops, this feature is broken in all kernel versions tested so far.

      KQUEUE:

      Kqueue deserves special mention, as at the time of this writing, it was broken on all BSDs except NetBSD (usually it doesn't work reliably with anything but sockets and pipes, except on Darwin, where of course it's completely useless). Unlike epoll, however, whose brokenness is by design, these kqueue bugs can (and eventually will) be fixed without API changes to existing programs. For this reason it's not being «auto-detected» unless you explicitly specify it in the flags (i.e. using EVBACKEND_KQUEUE) or libev was compiled on a known-to-be-good (-enough) system like NetBSD.

      You still can embed kqueue into a normal poll or select backend and use it only for sockets (after having made sure that sockets work with kqueue on the target platform).

      It scales in the same way as the epoll backend, but the interface to the kernel is more efficient (which says nothing about its actual speed, of course). While stopping, setting and starting an I/O watcher does never cause an extra system call as with EPOLL, it still adds up to two event changes per incident. Support for fork () is very bad (but sane, unlike epoll) and it drops fds silently in similarly hard-to-detect cases

      Я вряд ли смог бы написать лучше.
      • +1
        epoll пробовал и до сих пор пробую в проектах которые должны обрабатывать десятки тысяч единовременных коннектов. И пока не жалуюсь на них.
        Ничего плохого именно к этой статье я не имею, я говорю в общем о существующем
      • 0
        Автор libev излишне суров по отношению к bsd системам (мне кажется, тут что-то субъективное). На самом деле все не так плохо с kqueue. Есть особенности, да :)
    • 0
      >Кстати, для Windows с помощью IOCP можно сделать не чуть не хуже по производительности приложения
      у IOCP есть большой минус, нужно преалокэйтить буферы, отсюда плохая масштабируемость.

      >практически во всех статьях и книгах по программированию про это ни слова, а больше всего делается упор на select и poll
      Потомучто в большинстве приложений они более актуальны чем epoll итд. Меньше проблем с переносом на другие платформы, ничем не отличающаяся производительность, только масштабируются хуже. А те кто собирается писать сервер под >10к соединений, при первом же запросе в гугле найдут всю нужную инфу.
  • +5
    Автору спасибо, но есть несколько замечаний.

    > Итак, первым делом любой демон вызывает системную функцию bind(), которая создает файл специального типа «слушающий сокет».
    В корне неверно. Создаёт сокет сис. вызов socket(2). bind(2) связывает сокет с конкретным портом/сетевым интерфейсом, можно получить и клиентский (неслушающий) сокет с пом. bind. Вот listen(2) переводит сокет в слушающий режим и, если сокет ещё не связан с портом, то осуществляет принудительное связывание; после успешного вызова listen bind будет возвращать ошибку.

    >Потоки — это максимально легкие «процессы»
    Если вы говорите о UNIX, то необходимо упомянуть, что в Linux они никакие не лёгкие, структура в ядре для потоков и процессов та же самая, просто потоки имеют одно и то же адресное пространство. Т.к. все ключевые девелоперы ядра признают, что потоки в принципе — зло, то никто особо не чешется их оптимизировать.

    Незаслуженно забыта модель FSM, на которой основан nginx:
    https://groups.google.com/group/fido7.ru.unix.prog/browse_thread/thread/e8f8edf4f2f2447b/?hl=ru
    • 0
      Что касается socket, listen и пр. — они опущены для ясности. Да-да, и функции read() в современных версиях Windows тоже нет (есть _read и ReadFile). Извините, статья и так большая. Если указывать все функции, которые вызываются по 1-2 раза… ну вы поняли.

      FSM не назван как FSM, но описан в части 4. Просто в современных реалиях имеется по 8-16-32 ядер в каждом сервере, и чистый однопоточный FSM неактуален. А несколько работающих параллельно и асинхронно автоматов… наверное, это сеть Маркова, но надо смотреть детали.
      • 0
        Хм, да, про FSM — слоника-то я и не заметил, извините :)
        «Нечистый FSM» описан по ссылке, что я дал выше.
  • +1
    Никогда так не думал про связь процессов и прав почему то. Думал, что это чтоб при падении одного процесса не валилась вся программа.
  • +1
    Дал почитать приятелю статью, мол смотри как всё классно разжевали и в рот положили. А он в ответ говорит, что много лет назад для ввода-вывода предпочитал использовать как-раз unix select или WinSock2. В веб-программерской среде сейчас столько говорят про реализации серверов на неблокирующих сокетах и асинхронных задачах. А всё это новое есть ещё не позабытое старое.
    • –1
      Вот именно, не забытое. Я первый раз использовал select в 2002-ом. Просто сейчас все приложения выходят в интернет, соответственно всем так или иначе нужно организовывать серверные демоны. И тут вылезают всякие разные вопросы.
      • 0
        плюс высокие нагрузки заставляют людей заниматься оптимизацией и разбираться с системой
  • 0
    Также советую к прочтению FAQ из (аж) фидошной ru.unix.prog:

    groups.google.com/group/fido7.ru.unix.prog/browse_thread/thread/44db58f70be988ad

    Если нет гугл акка — искать по запросу «ru.uniq.prog как писать сервера».
    • 0
      читай комментарии, прежде чем писать, дебил
      • +4
        Ссылку добавил в список. Но имхо вы, батенька, как-то уж очень сурово сам с собою)
  • –1
    топик отличный
    спасибо
    но мне кажется его место в блоге *nix
  • 0
    Но в большинстве статей не объясняется, почему именно такой способ используется в том же Apache.
    Одна из причин указана Стивенсоном: процессы — более надежное звено нежели потоки, крах одного потока приведет к краху всего приложения.Разработчики группы Appache ставили в основу критерий — надежность WEB сервера
  • 0
    надо добавить ссылки на libevent, boost/aio libaio
  • НЛО прилетело и опубликовало эту надпись здесь
    • НЛО прилетело и опубликовало эту надпись здесь
  • 0
    Превосходно излагаете, спасибо.
  • 0
    Спасибо за содержательную статью.
  • 0
    Такая ситуация называется клинчем — разве не дедлоком?
    • 0
      Ну, клинч есть устоявшийся в русском языке термин, пусть и из бокса. Дедлок пока нет.
      • 0
        Честно говоря, термин «клинч» как-то озадачил при прочтении (про бокс, кроме того что в нём есть «нокаут», «нокдаун» и «хук» ничего не знаю), а вот с термином «дедлок» встречался даже когда параллельным программированием даже не интересовался.
  • 0
    Почти полностью переписал раздел про асинхронный ввод/вывод, спасибо комментаторам.
  • +1
    Прекрасно описано, с огромным удовольствием прочитал.
  • 0
    как будто poll() есть где-то, кроме некоторых *nix

    Socket.Poll Method
    • 0
      Имеется в виду групповой poll(), см. man poll(2)
      • 0
        Socket.Poll Method


        +

        Имеется в виду групповой poll(), см. man poll(2)


        =

        «эффективные реализации polling'а на сегодняшний день имеются лишь в *nix-системах»
  • +1
    отличная статья
    даже понятная
  • 0
    Вряд ли когда нибудь уже займусь столь низкоуровневым программированием, но некоторые «холивары» на тему настройки веб-серверов и СУБД стали намного понятнее. Вернее появилось осмысление почему эмпирически полученные значения являются оптимальными для некоторых условий и не являются универсальными
    • 0
      Блин, забыл дописать последней строчкой «Спасибо» — исправляю склероз

      Спасибо за статью!
  • +1
    > Исторически одной из первых ОС с поддержкой асинхронного ввода-вывода стала Windows 2000.
    Насколько я помню, асинхронный ввод-вывод и OVERLAPPED появились ещё в Windows NT 4.0, но этот динозавр уже не упоминается в текущей версии MSDN.

    Ну и немного поправок/уточнений по поводу асинхронного i/o в windows.

    1) callback-функции, передаваемые в функции чтения/записи — это отдельный механизм, называемый APC (Asynchronous Procedure Calls). Его можно использовать независимо от i/o и callback-функции работают через него. APC работает в пределах одного потока: асинхронный вызов можно назначить только в контексте одного конкретного потока. В случае i/o вызов будет сделан в контексте потока, инициализировавшего i/o.

    2) IOCP != OVERLAPPED. IOCP — это интегрированный с планировщиком потоков механизм, который позволяет организовать эффективный пул потоков для работы с OVERLAPPED i/o на многопроцессорных машинах. По сути это thread safe очередь, в которую можно помещать элементы, даже не обязательно связанные с i/o (PostQueuedCompletionStatus). Туда же помещаются результаты операций OVERLAPPED i/o. Несколько потоков в цикле получают элементы из очереди и обрабатывают эти элементы. При этом:

    a) Если несколько потоков ждут очередного элемента, то приоритет имеет поток, который последним запросил элемент. Это позволяет не тратить время на усыпление/пробуждение потока в случае, когда элемент очереди появился в момент запроса работающим потоком.

    b) Потоки, хоть раз запросившие элементы из очереди, считаются ассоциированными с этой очередью. Планировщик потоков работает с ними специальным образом. Если на машине N процессоров, а в пуле M потоков, и M > N (microsoft рекомендует делать M = N * 2), то не более N потоков будут пробуждены независимо от того, сколько элементов оказалось в очереди — это позволяет минимизировать количество переключений контекста потоков на одном процессоре. Если по каким-то причинам один из этих N потоков уснул не при запросе очередного элемента (например на событии), то планировщик пробуждает один из оставшихся потоков, ждущих очередного элемента очереди.
    Всё это позволяет добиться практически максимальной производительности на данном железе.

    Вообще, мужики из microsoft двигаются в правильном направлении. в win2k, например, не было функции ConnectEx, позволяющей асинхронно установить исходящее соединение, приходилось создавать отдельный поток. В xp/2003 эта функция появилась, плюс появилась DisconnectEx, позволяющая переиспользовать сокет для нескольких подключений и экономить на вызове socket. Была ещё одна проблема — получение адреса по имени хоста было только синхронным (getaddrinfo), в vista/2008 добавили асинхронную версию (GetAddrInfoEx). API постепенно улучшается и это радует.

    P. S. А вообще, boost.asio решает проблемы с эффективными кросплатформенными сетевыми приложениями ;)
    • +1
      Не подскажете, как асинхронно использовать GetAddrInfoEx()? В MSDN сказано, что асинхронные параметры зарезервированы и не поддерживаются :(
      • +1
        Мда, ошибочка вышла. Параметры то есть, но не работают. Я, когда смотрел новшества API, описание параметров не посмотрел — думал и так всё понятно.
  • 0
    Да, регрессии в ядре печалят :(
  • –1
    > Если в обычном, «блокирующем» режиме функция read читает из файла столько байт, сколько заказал программист

    read() может прочитать меньше чем заказано.

    man 2 read:

    RETURN VALUE
    On success, the number of bytes read is returned (zero indicates end of file), and the file position is advanced by this number. It is not an error if this number is smaller than the number of bytes requested; this may happen for example because fewer bytes are actually available right now [...]

Только зарегистрированные пользователи могут оставлять комментарии. Войдите, пожалуйста.