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

    Введение


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

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

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

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

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

    (Upd: данная статья — перевод)


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


    Первой и наиболее важной разницей между 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 (ну или как там вы назовёте тот класс, где будут храниться буферы получаемых/отправляемых данных). Он не должен уничтожаться, пока не завершаться запланированные операции чтения/записи, использующие его буферы.

    Инфопульс Украина

    194,00

    Creating Value, Delivering Excellence

    Поделиться публикацией

    Похожие публикации

    Комментарии 12
    • НЛО прилетело и опубликовало эту надпись здесь
        +1
        Это разве не перевод вот этой статьи Practical difference between epoll and Windows IO Completion Ports (IOCP)?
          0
          Блин, забыл изменить тип публикации. Добавил пометку о переводе и ссылку на оригинал в текст (Хабр не позволяет изменить тип уже опубликованной публикации)
            0

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

          0

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


          Паттерн "получили пакет; решили что надо ещё и сразу запустили второй recv" усложняется только если каждый раз дёргать сокет операциями с iocp. Но на самом деле никто не мешает сперва вызвать на неблокирующем сокете "голый" recv. Если там есть свежие данные (т.е. пока мы разбирались с протоколом, они уже нас ждали), то мы сразу их и получим. Если нет — вызываем WSARecv с overlapped и iocp. В случае шустрой работы сети это позволит в определённых ситуациях вообще не касаться пулера.

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

            Вот все варианты асинхронщины под одним интерфейсом
            think-async.com/Asio/asio-1.10.6/doc/asio/overview/implementation.html
              –1
              Под одним интерфейсом — это не то же самое что и на общей кодовой базе.
              +1
              Если операция ввода/вывода была запланирована — её можно отменить вызовом функции CancelIo(). Что хуже, вызвать эту функцию может лишь тот же поток, который запустил первоначальную операцию. Все идеи организации отдельного управляющего потока разбиваются об это ограничение.
              CancelIoEx() умеет то, что надо.
              Кроме того, даже после вызова CancelIo() мы не можем быть уверены, что операция будет немедленно отменена (возможно, она уже выполняется, использует структуру OVERLAPPED и переданный буфер для чтения/записи).
              Ровно так же как и для любых других операций: ждём уведомления.

              Разница в типе нотификаций делает возможным (и достаточно тривиальным) эмуляцию IOCP с помощью epoll. Например, проект Wine именно так и делает. Однако, проделать обратное не так просто. Даже если у вас получится — это, вероятно, приведёт к потере производительности.
              Тут дело скорее в том, что Overlapped IO само по себе и механизм уведомлений Completion Port более низкоуровневая абстракция, нежли механизмы epoll. Собственно OVL/IOCP суть отражение поведения ядра Windows на юзерспейс.
              epoll говорит вам когда дескриптор готов к тому, чтобы с ним можно было что-то сделать — «а сейчас вы можете начать читать данные»
              IOCP говорит вам когда запрошенная операция выполнена — «вы просили прочитать данные и вот они прочитаны»
              А с записью ситуация зеркальная и не очень понятно, что лучше, что хуже. С epoll() механизм управления буферами и буферизации реализован в ядре и это хорошо, но если вы вдруг завтра придумаете более эффективный способ — то придётся править ядро. А для Windows вы и не сможете поправить ядро. Поэтому вот.
                0

                А ещё из iocp невозможно удалить дескриптор. Только если его закрыть.
                Хотя… Возможно, но, извините, через задницу. И решение не на поверхности, ещё поискать надо...


                /*
                Hackers way to unbind from IOCP:
                
                Call NtSetInformationFile with the FileReplaceCompletionInformationenumerator value for 
                FileInformationClass and a pointer to a FILE_COMPLETION_INFORMATION structure for the FileInformation parameter. 
                In this structure, set the Port member to NULL (or nullptr, in C++) to disassociate the file from the port it's currently attached to (I guess if it isn't attached to any port, nothing would happen), or set Port to a valid HANDLE to another completion port to associate the file with that one instead.
                However it 1-st, require win >=8.1, and also invoke DDK stuff which is highly non-desirable. 
                */
                  +1
                  Понимаю, что статья не о том (она о том, что «под капотом» и о том, что стоит все равно держать в голове), но не могу не удержаться и не порекомендовать Asio (Boost.Asio). И в epoll, и в IOCP столько подводных камней (тот же баг с IOCP на старых Windows с залипанием асинхронных операций), что не стоит повторять путь автора Asio и разгребать это все самому. К тому же, в Asio уже есть удобные таймеры (несколько оптимизированные по сравнению с наивной реализацией «в лоб») и концепция Strand (не без изъянов, конечно, но за все надо чем-то платить). Networking TS (published) для C++ построена как раз на базе Asio.

                  В Asio как раз есть возможность использования реактивного подхода (то, что дает epoll) на базе IOCP.

                  Субъективно (есть тесты, но неважные и все на Asio — так что «чистыми» их не назовешь) IOCP хоть и более тесно интегрирован с ядром ОС (где-то даже встречал обещание LIFO при планировании потоков, вызывающих GetQueuedCompletionStatus), но выигрывает у epoll при использовании в многопоточном приложении в варианте «один пул потоков обслуживает все соединения (сокеты)», так как в случае IOCP ОС (Windows) как-то эффективнее работает с блокировками, нежели чем простой mutex на единственном epoll instance.

                  В случае Asio мне пришлось изменить приложение так, чтобы можно было конфигурировать приложение работать в одном из двух вариантов (по умолчанию на Windows используется первый, а на non-Windows — второй):
                  1. Один экземпляр boost::asio::io_service (т.е. один IOCP / epoll instance) и один пул из нескольких потоков по количеству logical CPU
                  2. По одному экземпляру boost::asio::io_service на каждый logical CPU (т.е. один IOCP / epoll instance на каждый logical CPU) и по одному потоку на каждый экземпляр boost::asio::io_service

                  Понятно, что во втором варианте пришлось как-то раскидывать / распределять нагрузку (количество обслуживаемых соединений / сокетов) по всем экземплярам boost::asio::io_service, что уже не так удобно и гибко, как первый вариант.

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

                  Ну и вот тут есть интересный комментарий по теме (по проблеме) необходимости преаллоцированных буферов в случае IOCP.
                    0
                    Есть еще одна алтернатива дла Windows. Она подходит для более интерактивного приложения чем сервис и гораздо гибче select.
                    Это возмозность связать сокет и Event. При етом на событие можно повесить несколько сокетов, а можно и один.

                    Выгладит ето так:
                    socketsEvent = ::WSACreateEvent();
                    
                    res = ::WSAEventSelect(
                                            socket, 
                                            socketsEvent, 
                                            FD_READ | FD_ACCEPT | FD_CLOSE);
                     
                    waitResult = ::WaitForMultipleObjectsEx(... list of all events ...);
                    
                    //Now check the socekt event
                    
                    if(waitResult  == SocketEventNumber)
                    {
                          //You have to check all sockets attached to the event
                          WSANETWORKEVENTS lpEvents;
                          ::memset(&lpEvents, 0, sizeof(lpEvents));
                          const int wsaRes = ::WSAEnumNetworkEvents(socket, socketsEvent, &lpEvents);
                    }
                    


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

                    И да, в отличие от select все это можно остановить.
                      +1

                      А что с масштабируемостью?
                      Привяжу к ивенту 50 тыс. сокетов (да, сразу первая досада — маска событий одна на всех; уже не выйдет какие-то только на запись, а другие — только на чтение).
                      Ну, дождётся он события. И дальше что? Перебирать всю кучу и искать, где поменялось?
                      Дык для этого как раз poll есть.
                      А смысл epoll/kqueue/iocp именно в том, что неважно, сколько у тебя коннектов. Они вернут из ожидания только те, где случилось запрошенное событие.
                      И обрабатывая десяток прилетевших коннектов я даже и знать не буду, что их там "в ожидании" на самом деле десятки тысяч.

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

                    Самое читаемое