Должны ли мы использовать несколько акцепторных сокетов для приема большого количества соединений?

Как известно, SO_REUSEPORT позволяет нескольким сокетам прослушивать один и тот же IP-адрес и порт, он увеличивает количество запросов в секунду от 2 до 3 раз и уменьшает время ожидания (~ 30%) и стандартное отклонение для задержки (8 раз): https://www.nginx.com/blog/socket-sharding-nginx-release-1-9-1/

В выпуске NGINX 1.9.1 представлена ​​новая функция, которая позволяет использовать SO_REUSEPORT, который доступен в более новых версиях многих операционных систем, включая DragonFly BSD и Linux (ядро версия 3.9 и выше). Эта опция сокета позволяет использовать несколько сокетов для прослушивания одного и того же IP-адреса и комбинации портов. Ядро затем нагрузка выравнивает входящие соединения через сокеты....

Как показано на рисунке, повторное использование увеличивает количество запросов в секунду на 2 до 3 раз и уменьшает как латентность, так и стандартное отклонение для латентность.

введите описание изображения здесь

введите описание изображения здесь

введите описание изображения здесь


SO_REUSEPORT доступен на большинстве современных ОС: Linux (kernel >= 3.9, поскольку 29 апреля 2013 г.), Free/Open/NetBSD, MacOS, iOS/watchOS/tvOS, IBM AIX 7.2, Oracle Solaris 11.1, Windows (только SO_REUSEPORT, который ведет себя как два флага вместе SO_REUSEPORT + SO_REUSEADDR в BSD) и может быть на Android: qaru.site/info/15470/...

Linux >= 3.9

  1. Кроме того, ядро ​​выполняет некоторую "специальную магию" для сокетов SO_REUSEPORT, которая не найдена в других операционных системах: Для сокетов UDP он пытается распределить дейтаграммы равномерно, для TCP прослушивающих сокетов, он пытается распространять входящие запросы на подключение(те, которые принимаются путем вызова accept()) равномерно по всем сокетам которые используют один и тот же адрес и комбинацию портов. Таким образом приложение может легко открыть один и тот же порт в нескольких дочерних процессах, а затем использовать SO_REUSEPORT, чтобы получить очень недорогую балансировку нагрузки.

Также известно, что во избежание блокировки спин-блокировки и достижимой высокой производительности не должно быть сокетов, которые считывают более 1 потока. То есть каждый поток должен обрабатывать собственные сокеты для чтения/записи.

  • accept() - это поточно-безопасная функция для одного и того же дескриптора сокета, поэтому его следует защищать с помощью блокировки - , так что конфликт блокировки снижает производительность: http://unix.derkeiler.com/Newsgroups/comp.unix.programmer/2007-06/msg00246.html

POSIX.1-2001/SUSv3 требует accept(), bind(), connect(), listen(), socket(), send(), recv() и т.д., чтобы быть потокобезопасными функциями.возможно, что в стандарте имеются некоторые двусмысленности в отношении их взаимодействие с потоками, но предполагается, что их поведение в многопоточных программах определяется стандартом.

  • Если мы используем один и тот же один сокет из многих потоков, тогда производительность будет низкой, потому что сокет защищен блокировкой для потокобезопасного доступа из многих потоков: https://blog.cloudflare.com/how-to-receive-a-million-packets/

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

V. K ERNEL ISOLATION

....

С другой стороны, , когда приложение пытается прочитать данные из сокета, он выполняет аналогичный процесс, который описан ниже и представлен на рисунке 3 справа налево:

1) Удалите один или несколько пакетов из очереди приема, , используя соответствующая спин-блокировка (зеленая).

2) Скопируйте информацию в память пользовательского пространства.

3) Отпустите память, используемую пакетом. Эта потенциально изменяет состояние сокета, поэтому два способа блокировки сокет может происходить: быстро и медленно. В обоих случаях пакет несвязанные из сокета, статистика учета памяти обновляется и сокет выдается в соответствии с принятым каналом блокировки.

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


У нас есть 2 x Xeon 32 HT-Cores сервер с 64-мя всеми HT-ядрами и две 10-гигабитные Ethernet-карты и Linux (ядро 3.9).

Мы используем RFS и XPS - то есть для одного и того же соединения обработанного TCP/IP-стека (пространство ядра) на одном CPU-Core в качестве прикладного потока (пользовательского пространства).

Существует как минимум 3 способа приема подключений к процессам во многих потоках:

  • Используйте один акцепторный сокет, общий для многих потоков, и каждый поток принимает соединения и обрабатывает его
  • Используйте один акцепторный сокет в 1 потоке, и этот поток нажимает на дескрипторы дескрипторов подключений к другим потоковым рабочим с помощью потоковой безопасности
  • Используйте много сокетов-приемников, которые прослушивают один и тот же ip:port, 1 отдельный акцепторный сокет в каждом потоке, и поток, который получает соединение, затем обрабатывает его (recv/отправить)

Чем эффективнее, если мы принимаем много новых TCP-соединений?

Ответ 1

Чтобы справиться с подобным случаем в производстве, вот хороший способ подойти к этой проблеме:

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

Отметьте это ядро ​​как неиспользуемое а затем явно запросите его в своем коде для потока "слушать сокет" через cpuset.

Затем настройте очередь (в идеале, приоритетную очередь), которая приоритирует операции записи (то есть "вторая проблема читателей-писателей" )., установите все рабочие потоки, как вы видите разумно.

На этом этапе цель потока входящих соединений должна быть:

  • accept() входящих соединений.
  • Как можно быстрее передайте эти дескрипторы файла подключений (FD) в структуру очереди, приоритетную для писателя.
  • Вернитесь в состояние accept() как можно быстрее.

Это позволит вам делегировать входящие соединения как можно быстрее. Ваши рабочие потоки могут захватывать элементы из общей очереди по мере их поступления. Также может потребоваться второй, высокоприоритетный поток, который захватывает данные из этой очереди и перемещает их во вторичную очередь, сохраняя поток "слушать в сокете" из необходимости тратить дополнительные циклы, делегирующие клиентские FD.

Это также помешало бы потоку "слушать сокет" и рабочим потокам получать доступ к одной и той же очереди одновременно, что избавило бы вас от худших сценариев, таких как медленный рабочий поток, блокирующий очередь, когда "слушать socket" хочет удалить данные в нем. то есть.

Incoming client connections

 ||
 || Listener thread - accept() connection.
 \/

Listener/Helper queue

 ||
 || Helper thread
 \/

Shared Worker queue

 ||
 || Worker thread #n
 \/

Worker-specific memory space. read() from client.

Что касается двух других предложенных вариантов:

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

Грязное. Потокам придется как-то по очереди выдавать вызов accept(), и для этого не будет никакой пользы. У вас также будет дополнительная логика секвенирования для обработки того, какой поток "поворот" вверх.

Используйте многие приемные сокеты, которые прослушивают один и тот же ip: порт, 1 индивидуальный акцепторного сокета в каждом потоке, а поток, который принимает соединение затем обрабатывает его (recv/send)

Не самый портативный вариант. Я бы избежал этого. Кроме того, вам может потребоваться сделать ваш серверный процесс многопроцессорным (т.е. fork()), в отличие от многопоточных, в зависимости от ОС, версии ядра и т.д.

Ответ 2

Предполагая, что у вас есть два сетевых подключения 10 Гбит/с и при условии, что средний размер кадра в 500 байт (который очень консервативен для сервера без интерактивного использования), у вас будет около 2 Мпакетов в секунду на каждую сетевую карту (я не верю, что у вас больше чем это), а это означает обработку 4 пакетов на микросекунду. Это очень медленная латентность для процессора, такого как тот, который описан в вашей конфигурации. В этих помещениях я гарантирую, что ваше узкое место будет находиться в сети (и переключателях, к которым вы подключаетесь), чем в спин-блокировке на каждом сокете (требуется несколько циклов процессора для разрешения на спин-блокировку, и это далеко за пределом, налагаемым сеть). Либо я выделил нить или два (один для чтения и прочее для записи) максимум на каждой сетевой карте, и не думаю, что в функциях блокировки сокетов не будет больше. Наиболее вероятно, что ваше узкое место находится в прикладном программном обеспечении, которое у вас есть в бэкэнд этой конфигурации.

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

Ответ 3

Используйте множество сокетов-приемников, которые прослушивают один и тот же ip: порт, 1 отдельный акцепторный сокет в каждом потоке, и поток, который получает соединение, затем обрабатывает его (recv/send)

Это невозможно в TCP. Забудьте об этом.

Делайте то, что делают все остальные. Один принимающий поток, который запускает новый поток на принятый сокет или отправляет их в пул потоков.