epoll и Windows IO Completion Ports: практическая разница

в 9:25, , рубрики: epoll, IOCP, Блог компании Инфопульс Украина, Разработка под Linux, разработка под windows, Сетевые технологии, системное программирование

Введение

В этой статье мы попробуем разобраться чем на практике отличается механизм epoll от портов завершения (Windows I/O Completion Port или IOCP). Это может быть интересно системным архитекторам, проектирующим высокопроизводительные сетевые сервисы или программистам, портирующим сетевой код с Windows на Linux или наоборот.

Обе эти технологии весьма эффективны для обработки большого количества сетевых соединений.

Они отличаются от других методов по следующим пунктам:

  • Нет ограничений (кроме общих ресурсов системы) на общее количество наблюдаемых дескрипторов и типов событий
  • Масштабирование работает достаточно хорошо — если вы уже мониторите N дескрипторов, то переход к мониторингу N + 1 займёт очень мало времени и ресурсов
  • Достаточно легко задействовать пул потоков для параллельной обработки происходящих событий
  • Нет никакого смысла использовать при единичных сетевых соединениях. Все преимущества начинают проявляться при 1000+ соединений

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

Тип нотификаций

Первой и наиболее важной разницей между epoll и IOCP является то, как вы получаете извещение о случившемся событии.

  • epoll говорит вам когда дескриптор готов к тому, чтобы с ним можно было что-то сделать — "а сейчас вы можете начать читать данные"
  • IOCP говорит вам когда запрошенная операция выполнена — "вы просили прочитать данные и вот они прочитаны"

При использовании epoll приложение:

  • Решает, какую именно операцию оно хочет выполнить с некоторым дескриптором (чтение, запись или обе операции)
  • Устанавливает соответствующую маску при помощи epoll_ctl
  • Вызывает epoll_wait, что блокирует текущий поток пока не произойдёт минимум одно ожидаемое событие (или истечёт время ожидания)
  • Перебирает полученные события, берёт указатель на контекст (из поля data.ptr)
  • Инициирует обработку событий в соответствии с их типом (чтение, запись или обе операции)
  • После окончания выполнения операции (что должно произойти немедленно) продолжает ожидание получения/отправки данных

При использовании IOCP приложение:

  • Инициирует проведение некоторой операции (ReadFile или WriteFile) для некоторого дескриптора, при этом используя непустой аргумент OVERLAPPED. Операционная система добавляет требование выполнения данной операции себе в очередь, а вызываемая функция немедленно (не ожидая завершения операции) возвращается.
  • Вызывает GetQueuedCompletionStatus(), которая блокирует текущий поток пока не завершится ровно один из добавленных ранее запросов. Если завершилось несколько — будет выбран лишь один из них.
  • Обрабатывает полученное извещение о завершении операции, используя для этого ключ завершения (completion key) и указатель на OVERLAPPED.
  • Продолжает ожидание получения/отправки данных

Разница в типе нотификаций делает возможным (и достаточно тривиальным) эмуляцию IOCP с помощью epoll. Например, проект Wine именно так и делает. Однако, проделать обратное не так просто. Даже если у вас получится — это, вероятно, приведёт к потере производительности.

Доступность данных

Если вы планируете читать данные, то в вашем коде должен быть какой-то буфер, куда вы планируете их читать. Если вы планируете отправлять данные, то должен быть буфер с данными, готовыми к отправке.

  • epoll нисколько не беспокоится о наличии этих буферов и никак их не использует
  • IOCP эти буферы нужны. Вся суть использования IOCP — это работа в стиле «а прочитай-ка мне 256 байт из этого сокета вот в этот буфер». Сформировали такой запрос, отдали его ОС, ждём нотификации о завершении операции (и не трогаем буфер в это время!)

Типичный сетевой сервис оперирует объектами соединений, который включат в себя дескрипторы и связанные с ними буферы для чтения/записи данных. Обычно эти объекты уничтожаются при закрытии соответствующего сокета. И это накладывает некоторые ограничения при использовании IOCP.

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

  • Вы не можете использовать в качестве буфера локальные переменные (размещённые в стеке). Буфер должен быть валиден до окончания ожидания завершения операции чтения/записи, а стек уничтожится при выходе из текущей функции
  • Вы не можете на ходу переаллоцировать буфер (например, оказалось, что нужно отправить больше данных и вы хотите увеличить буфер). Вы можете только создать новый буфер и новый запрос на отправку
  • Если вы пишете что-то типа прокси, когда одни и те же данные будут и читаться, и отправляться — вам придётся использовать для них два отдельных буфера. Вы не можете в одном запросе попросить ОС читать данные в какой-то буфер, а в другом — тут же из него же эти данные отправлять
  • Вам нужно хорошо задуматься о том, как ваш класс менеджера соединений будет уничтожать каждое конкретное соединение. У вас должна быть полная гарантия того, что на момент уничтожения соединение нет ни одного запроса на чтение/запись данных с использованием буферов данного соединения

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

epoll не использует никаких передаваемых ему из пользовательского кода буферов, так что все эти проблемы его никак не касаются.

Изменение условий ожидания

Добавление нового типа ожидаемых событий (например, мы ждали возможности прочитать данные из сокета, а теперь захотели ещё и получить возможность отправить их) возможно и достаточно просто и для epoll и для IOCP. epoll позволяет изменить маску ожидаемых событий (в любое время, даже из другого потока), а IOCP позволяет запустить ещё одну операцию ожидания нового типа событий.

Изменение или удаление уже ожидаемых событий, однако, отличается. epoll всё так же позволяет модифицировать условие с помощью вызова epoll_ctl (в том числе из других потоков). С IOCP всё сложнее. Если операция ввода/вывода была запланирована — её можно отменить вызовом функции CancelIo(). Что хуже, вызвать эту функцию может лишь тот же поток, который запустил первоначальную операцию. Все идеи организации отдельного управляющего потока разбиваются об это ограничение. Кроме того, даже после вызова CancelIo() мы не можем быть уверены, что операция будет немедленно отменена (возможно, она уже выполняется, использует структуру OVERLAPPED и переданный буфер для чтения/записи). Нам всё равно придётся дождаться завершения операции (её результат будет возвращён функцией GetOverlappedResult()) и лишь после этого мы сможем освободить буфер.

Ещё одна проблема с IOCP в том, что как только операция была запланирована к выполнению — она уже не может быть изменена. Например, вы не можете изменить запланированный запрос ReadFile и сказать, что хотите прочитать лишь 10 байт, а не 8192. Вам нужно отменять текущую операцию и запускать новую. Это не проблема для epoll, который при запуске ожидания понятия не имеет, сколько данных вы захотите прочитать на тот момент, когда придёт нотификация о возможности чтения данных.

Неблокируемое соединение

Некоторые реализации сетевых сервисов (связанные сервисы, FTP, p2p) требуют организации исходящих соединений. И epoll, и IOCP поддерживают неблокируемый запрос на соединение, но по-разному.

При использовании epoll код, в общем, такой же как и для select или poll. Вы создаёте неблокируемый сокет, вызываете для него connect() и ждёте нотификации о его доступности для записи.

При использовании IOCP вам нужно использовать отдельную функцию ConnectEx, поскольку вызов connect() не принимает структуру OVERLAPPED, а значит не может позже сгенерировать нотификацию об изменении состояния сокета. Так что код инициирования соединения будет отличаться не только от кода с использованием epoll, он будет отличаться даже от Windows-кода, использующего select или poll. Однако, изменения можно считать минимальными.

Что интересно, accept() работает с IOCP как обычно. Есть и функция AcceptEx, но её роль совершенно не связанная с неблокируемым соединением. Это не «неблокируемый accept», как можно было бы подумать по аналогии с connect/ConnectEx.

Мониторинг событий

Часто после срабатывания какого-то события очень быстро приходят дополнительные данные. Например, мы ожидали прихода входных данных из сокета при помощи epoll или IOCP, получили событие о первых нескольких байт данных и тут же, пока мы их читали, пришла ещё сотня байт. Можно ли прочитать их без перезапуска мониторинга событий?

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

С IOCP это не сработает. Если вы попросили сокет прочесть или отправить 10 байт данных — именно столько и будет прочитано/отправлено (даже если уже можно было бы и больше). Для каждого следующего блока нужно делать отдельный запрос с помощью ReadFile или WriteFile, а потом ждать, пока он будет выполнен. Это может создать дополнительный уровень сложности. Рассмотрим следующий пример:

  1. Класс сокета создал запрос на чтение данных с помощью вызова ReadFile. Потоки А и B ожидают результата, вызвав GetOverlappedResult()
  2. Операция чтения завершилась, поток А получил нотификацию и вызвал метод класса сокета для обработки полученных данных
  3. Класс сокета решил, что этих данных недостаточно, нужно ожидать следующих. Он размещает ещё один запрос на чтение.
  4. Этот запрос выполняется немедленно (данные уже пришли, ОС может отдать их немедленно). Поток В получает нотификацию, читает данные и передаёт их классу сокета.
  5. В данный момент функция чтения данных в классе сокета вызвана из обоих потоков А и В, что ведёт либо к риску повреждения данных (без использования объектов синхронизации), либо к дополнительным паузам (при использовании объектов синхронизации)

С объектами синхронизации в данном случае вообще сложно. Хорошо, если он один. Но если у нас будет 100 000 соединений и в каждом будет по какому-то объекту синхронизации — это может серьёзно ударить по ресурсам системы. А если ещё держать по 2 (на случай разделения обработки запросов на чтение и запись)? Ещё хуже.

Обычным решением здесь является создание класса менеджера соединений, который будет ответственным за вызов ReadFile или WriteFile для класса соединения. Это работает лучше, но делает код более сложным.

Выводы

И epoll, и IOCP подходят (и используются на практике) для написания высокопроизводительных сетевых сервисов, способных обрабатывать большое количество соединений. Сами технологии отличаются способами обработки событий. Эти отличия столь существенны, что вряд ли стоит пытаться писать их на какой-то общей базе (количество одинакового кода будет минимально). Я несколько раз работал над попытками привести оба подхода к какому-то универсальному решению — и каждый раз полученный результат получался хуже в плане сложности, читабельности и поддержки по сравнению с двумя независимыми реализациями. От полученного универсального результата каждый раз приходилось в итоге отказываться.

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

Советы:

  • Если вашей задачей является разработка кроссплатформенного сетевого сервиса, начать стоит с реализации на Windows с использованием IOCP. Как только всё будет готово и отлажено — добавьте тривиальный epoll-backend.
  • Не стоит пытаться писать общие классы Connection и ConnectionMgr, реализующие одновременно логику работы epoll и IOCP. Это плохо выглядит с точки зрения архитектуры кода и приводит к куче всяких #ifdef с разной логикой внутри них. Лучше сделайте базовые классы и унаследуйте отдельные реализации от них. В базовых классах можно держать какие-то общие методы или данные, если таковые будут.
  • Внимательно следите за временем жизни объектов класса Connection (ну или как там вы назовёте тот класс, где будут храниться буферы получаемых/отправляемых данных). Он не должен уничтожаться, пока не завершаться запланированные операции чтения/записи, использующие его буферы.

Автор: tangro

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js