Epoll И Порты Завершения Ввода-Вывода 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() , который блокирует текущий поток до тех пор, пока не завершится ровно один из ранее добавленных запросов.

    Если выполнено несколько, будет выбран только один из них.

  • Обрабатывает полученное уведомление о завершении операции, используя ключ завершения и указатель на OVERLAPPED.
  • Продолжает ожидать получения/отправки данных
Разница в типе уведомлений позволяет (и довольно тривиально) эмулировать IOCP с помощью epoll. Например, проект Вино это именно то, что он делает. Однако сделать наоборот не так-то просто.

Даже если вам это удастся, это, скорее всего, приведет к потере производительности.



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

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

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

  • epoll совершенно не обеспокоен наличием этих буферов и никак их не использует
  • IOCP нужны эти буферы.

    Весь смысл использования IOCP заключается в работе в стиле «прочитай мне 256 байт из этого сокета в этот буфер».

    Сформировали такой запрос, отправили его в ОС, ждем уведомления о завершении операции (и буфер в это время не трогаем!)

Типичная сетевая служба работает с объектами подключения, которые включают дескрипторы и связанные с ними буферы для чтения/записи данных.

Обычно эти объекты уничтожаются при закрытии соответствующего сокета.

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

когда-то позже).

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

Более того, вы даже не сможете модифицировать данные в этих буферах во время ожидания.

Это накладывает важные ограничения:

  • Вы не можете использовать локальные переменные (расположенные в стеке) в качестве буфера.

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

  • Перераспределить буфер на лету нельзя (например, оказалось, что вам нужно отправить больше данных и вы хотите увеличить буфер).

    Вы можете только создать новый буфер и новый запрос на отправку.

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

    Нельзя в одном запросе попросить ОС считать данные в какой-то буфер, а потом сразу отправить эти данные из него в другом

  • Вам нужно тщательно подумать о том, как ваш класс менеджера соединений будет уничтожать каждое конкретное соединение.

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

Операции IOCP также требуют передачи указателя на структуру OVERLAPPED, которая также должна продолжать существовать (и не использоваться повторно) до завершения ожидаемой операции.

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

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

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

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

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

Однако изменение или удаление уже ожидаемых событий — это другое дело.

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

С IOCP все сложнее.

Если операция ввода-вывода была запланирована, ее можно отменить, вызвав функцию ОтменаИо() .

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

Все идеи по организации отдельного потока управления ломаются из-за этого ограничения.

Кроме того, даже после вызова CancelIo() мы не можем быть уверены, что операция будет немедленно отменена (она может уже выполняться с использованием структуры OVERLAPPED и переданного буфера чтения/записи).

Нам все равно придется дождаться завершения операции (ее результат вернет функция GetOverlappedResult()) и только после этого мы сможем освободить буфер.

Другая проблема IOCP заключается в том, что если выполнение операции запланировано, ее нельзя изменить.

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

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



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

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

И epoll, и IOCP поддерживают неблокирующие запросы на соединение, но по-разному.

При использовании epoll код обычно такой же, как и при выборе или опросе.

Вы создаете неблокирующий сокет, вызываете для него метод Connect() и ждете уведомления о том, что он доступен для записи.

При использовании IOCP вам необходимо использовать отдельную функцию ConnectEx, поскольку вызов Connect() не принимает структуру OVERLAPPED и, следовательно, не может позже генерировать уведомление об изменении состояния сокета.

Таким образом, код инициации соединения будет не только отличаться от кода, использующего epoll, он даже будет отличаться от кода Windows, использующего select или poll. Однако изменения можно считать минимальными.

Интересно, что Accept() работает с IOCP как обычно.

Еще есть функция AcceptEx, но ее роль совершенно не связана с неблокирующим соединением.

Это не «неблокирующий акцепт», как можно подумать по аналогии с Connect/ConnectEx.

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

Часто после срабатывания события очень быстро поступают дополнительные данные.

Например, мы ждали поступления входных данных из сокета с помощью epoll или IOCP, получали событие о первых нескольких байтах данных, а затем, пока мы их читали, приходили еще сто байт. Можно ли их прочитать, не перезапуская мониторинг событий? Это возможно с помощью epoll. Вы получаете событие «что-то теперь доступно для чтения» — и читаете все, что можно прочитать из сокета (пока не получите ошибку EAGAIN).

То же самое и с отправкой данных — получив сигнал о том, что сокет готов отправлять данные, в него можно что-то писать, пока функция записи не вернет EAGAIN. Это не будет работать с IOCP. Если вы попросили сокет прочитать или отправить 10 байт данных, именно столько будет прочитано/отправлено (даже если уже можно было сделать больше).

Для каждого последующего блока нужно делать отдельный запрос с помощью ReadFile или WriteFile, а затем ждать его завершения.

Это может создать дополнительный уровень сложности.

Рассмотрим следующий пример:

  1. Класс сокета создал запрос на чтение данных с помощью вызова ReadFile. Потоки A и B ждут результата, вызывая GetOverlappedResult().

  2. Операция чтения завершена, поток A получил уведомление и вызвал метод класса сокета для обработки полученных данных.

  3. Класс сокета решил, что этих данных недостаточно, нужно дождаться следующих.

    Он размещает еще один запрос на чтение.

  4. Этот запрос выполняется немедленно (данные уже поступили, ОС может вернуть их немедленно).

    Поток B получает уведомление, считывает данные и передает их классу сокета.

  5. В настоящее время функция чтения данных в классе сокета вызывается из обоих потоков A и B, что приводит либо к риску повреждения данных (без использования объектов синхронизации), либо к дополнительным паузам (при использовании объектов синхронизации).

В этом случае синхронизация объектов вообще затруднена.

Хорошо, если он один.

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

А что, если оставить по 2 штуки (на случай, если обработка запросов на чтение и запись разделена)? Еще хуже.

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

Это работает лучше, но делает код более сложным.



выводы

И epoll, и IOCP подходят (и используются на практике) для написания высокопроизводительных сетевых сервисов, способных обрабатывать большое количество соединений.

Сами технологии различаются по способу обработки событий.

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

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

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

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

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

    Лучше создайте базовые классы и наследуйте от них отдельные реализации.

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

  • Внимательно следите за временем жизни объектов класса Connection (или как вы называете класс, где будут храниться буферы полученных/отправленных данных).

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

Теги: #Разработка для Linux #Разработка для Windows #Сетевые технологии #Системное программирование #epoll #IOCP
Вместе с данным постом часто просматривают:

Автор Статьи


Зарегистрирован: 2019-12-10 15:07:06
Баллов опыта: 0
Всего постов на сайте: 0
Всего комментарий на сайте: 0
Dima Manisha

Dima Manisha

Эксперт Wmlog. Профессиональный веб-мастер, SEO-специалист, дизайнер, маркетолог и интернет-предприниматель.