RIO Sockets

Задумывались ли вы когда то о том, сколько операций выполняет Windows во время работы вашего приложения с сокетами?

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

Пока wasm валяется в коме, решили сделать кросс-пост здесь!

Все это сказывается на скорости обработки запросов и бывают случаи, когда значение имеют даже доли секунд и именно для таких случаев ребята из Редмонда вместе с Windows 8 представили расширение для Winsock API – Registered Input/Output.

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

МОДЕЛЬ OVERLAPPED WINSOCK

Для лучшего понимания того, какие преимущества предлагает RIO, необходимо рассмотреть как же работают обычные виндовые overlapped сокеты. Работу с сокетами можно условно разделить на три этапа:

  1. Инициализация
  2. Обработка
  3. Завершение Давайте коротенько пройдемся по каждому из этих этапов на примере получения данных. Для лучшего понимания того, о чем мы будем говорить, мы воспользуемся схемами которые любезно разработал майкрософт.

Итак, этап 1 – Инициализация:

Для нас, как для кодеров, все что требуется сделать это написать одну строчку кода – WSARecv(socket, buf)(предварительно выделив память для buf), но вот система должна сделать для нас еще кое что:

  1. Поместить запрос на получение данных (то что на схеме изображено как I/O Request на уровне Winsock/Transport)
  2. Перед тем как вернуть управление в наше приложение, необходимо также залочить наш буффер в памяти (зеленый квадрат в Physical Memory)

1

Идем дальше, приходят данные, этап 2 – Обработка (в этом случае наверное лучше сказать “получение”):

На нашу сетевую карту пришли данные, что происходит в этом случае? Получив данные, сетевая карта помещает их через DMA в буффер, который выделен специально для неё (нижний зелёный квадрат в Physical Memory) и докладывает об этом на уровень Winsock/Transport.

Он в свою очередь видит наш запрос, который ожидает данных (I/O Request) и копирует (зелёная стрелка) данные из буффера сетевухи в буффер, который мы создали в приложении. Казалось бы все, не так ли? Эээ нет, нашему приложению еще нужно как то узнать о том, что данные были получены. И этим мы займёмся на третьем этапе.

2

Этап 3 – Завершение (для тех кто никак не может догнать что за “завершение” такое оставлю еще англ. вариант –completion):

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

Скопировав полученные данные в буффер приложения, Winsock/Transport спокойно может снять блокировку с тех участков памяти, которые были залочены на этапе инициализации (обратите внимание на схему – Physical Memory не содержит никаких квадратов).

После этого, Winsock/Transport оповещает I/O Manager о наличии данных, тот, в свою очередь, зная что наше приложение использует IOCP, помещает пакет завершения в completion queue (на схеме белый список).

На этом все, система свою работу закончила… Но ведь мы так и не узнали о том, что мы получили данные. Для этого мы вызываем GetQueuedCompletionStatus чтобы проверить что же новенького у нас есть в “почтовом ящике”.

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

3

Думаю, внимательный читатель уже мог задаться вопросом – “Чуууувааак, и это все повторяется для каждого обрабатываемого запроса?”. И ответ – да! Для каждого запроса система лочит/анлочит память, выполняются системные вызовы и идет сопоставление хендлеров всех используемых объектов.

Все это сделано для того, чтобы защитить приложения друг от друга а также саму систему от приложений. Но вопрос тут таков – а зачем это делать для каждого запроса? А незачем! И именно на этом утверждение реализовано расширение RIO.

REGISTERED INPUT/OUTPUT

Ну что ж, посмотрев на недостатки “традиционной” модели, давайте перейдем к нашим новым RIO сокетам. Основная идея RIO заключается в том, что в начале работы приложения мы регистрируем буффер с которым будем работать на протяжение всего времени жизни приложения. Один буффер для всех операций, одна блокировка этой области памяти и один анлок. Все. Просто и эффективно.

Еще одной концепцией RIO является использование не только очередей завершения (completion queue, CQ), но и очередей запросов (request queue, RQ), которая является индивидуальной для каждого сокета. Причем эти очереди создаются в юзер спейсе и мапятся в кернел спейс.

Это позволяет эффективно работать с ними как из ядра, так и из юзер мода, без лишних сопоставлений хендлеров. Память, выделенная под CQ и RQ так же постоянно заблокирована, что помогает еще эффективней работать с ней.

Итак, как же теперь выглядит работа системы? Возьмем все тот же пример с получением данных, но теперь воспользуемся функцией – RIOReceive(rq, rbid):

  1. Через очередь запросов оставляем запрос на receive
  2. Запрос маппится в кернел спейс
  3. Приходят данные, сетевая карта оповещает об этом уровень Winsock/Transport, тот получает данные и помещает пакет завершения в CQ.
  4. Пакет завершения маппится из кернел спейса в юзер спейс.
  5. С помощью функции RIODequeueCompletion, приложение проверяет наличие данных в буффере.

4

В итоге, на выходе имеем:

  1. Буффер блокируется единожды на время работы приложения
  2. RQ и CQ создаются в юзер спейсе и заблокированы так же на постоянной основе. Это значительно ускоряет работу с ними из за отсутствия системных вызовов в ядро и отсутствия процесса сопоставления хендлеров.

Что ж, самое время покодить!

КОДИНГ

Итак, с чего начнем? Конечно же нам нужно создать сокет:

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

Теперь мы можем вызывать новые API, через переменную rioFuncs.

Что дальше? Нам нужны наши очереди CQ и RQ. Создаем CQ первой, так как при создании RQ необходимо указывать CQ:

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

  1. None – будем использовать polling
  2. IOCP
  3. Виндовые ивенты

Тут как говорится к чему душа больше лежит. Я покажу как заполнять эту структуру на примере IOCP:

CQ создали, можем создать теперь RQ:

Как видим, все очень просто и понятно – передаем в качестве параметров наш сокет и CQ.

Так же скажу, что для каждого сокета может существовать только одна RQ, но может быть несколько CQ, например одна для отправки сообщений, а другая для получения. И наоборот – несколько сокетов могут пользоваться одной CQ.

На данном этапе мы имеем сокет и очереди завершения и запросов. Что делаем дальше? Правильно, нам нужно зарегистрировать буффер, ведь это Registered I/O:

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

Обратите внимание, что в качестве BufferId для “кусочков” указываем айди полученный с помощью RIORegisterBuffer.

Ну что же, на данном этапе мы имеем все необходимое для получения/отправки. Давайте же это сделаем!

В этом вызове стоит отметить два интересных момента:

  1. Мы используем не сокет напрямую, а очередь сообщений.
  2. Используется RIO_BUF, созданный на предыдущем этапе.

Таким образом, мы поместили запрос на получение данных, но ведь нам нужно контролировать появление этих данных. Для этого нам достаточно вызвать GetQueuedCompletionStatus для ожидания входящих данных, и как только такие данные появятся – выполнить RIODequeueCompletion и получить результаты из CQ:

После этого переменная results будет содержать массив результатов. Вот собственно и все.

Рабочий пример сервера

ТЕСТЫ

Так какой же реально выигрыш в скорости от RIO? Бородатый дядька из майкрософта утверждает что задержки уменьшаются на 15-30%, а так же пропускная способность была увеличена с 2 миллионов дейтаграмм до 4!

Ребята с serverframework.com провели свои испытания и получили такие результаты:

Обычный многопоточный IOCP UDP сервер:

  • 384000 дейтаграмм за секунду
  • Использование линка 10Гб – 33%
  • Обработано порядка 99% переданных дейтаграмм
  • Все 8 из доступных потоков обрабатывали запросы

Многопоточный RIO UDP сервер:

  • 492000 дейтаграмм за секунду
  • Использование линка 10Гб – 43%
  • Обработано 100% переданных дейтаграмм
  • Использовались только 4 из 8 потоков. Из этих 4 потоков, один практически ничего не делал, еще один обработал небольшое количество дейтаграмм, и еще два сделали основную массу работы.

P.S. Спасибо ребятам из VxLab за помощь в написание статьи.

На этом все, с вами был scarn3r. Хорошего настроения и держитесь тут 😉

5 thoughts on “RIO Sockets

    1. Насколько мне известно – да, проблема все еще остается. Вы можете указать флаг TCP_NODELAY, но алгоритм Нейгла все равно будет использоваться. На данный момент не видел способа отключить его для RIO сокетов.

Добавить комментарий