Миллион одновременных соединений

от автора

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

Адрес TCP/IP поддерживает только 65000 подключений, поэтому придётся назначить этому серверу примерно 30000 IP-адресов.

Существует 65535 номеров TCP-портов, значит ли это, что к TCP-серверу может подключиться не более 65535 клиентов? Можно решить, что это накладывает строгое ограничение на количество клиентов, которые может поддерживать один компьютер/приложение.

Если есть ограничение на количество портов, которые может иметь одна машина, а сокет можно привязать только к неиспользуемому номеру порта, как с этим справляются серверы, имеющие чрезвычайно большое количество запросов (больше, чем максимальное количество портов)? Эта проблема решается распределением системы, то есть кучей серверов на множестве машин?

Поэтому я написал эту статью, чтобы развеять данный миф с трёх сторон:

  1. Мессенджер WhatsApp и веб-фреймворк Phoenix, построенный на основе Elixir, уже продемонстрировали миллионы подключений, прослушивающих один порт.
  2. Теоретические возможности на основе протокола TCP/IP
  3. Простой эксперимент с Java, который может провести на своей машине любой, если его всё ещё не убедили мои слова.

Если вы не хотите изучать подробности, то перейдите в раздел «Итоги» в конце статьи.

Эксперименты

Фреймворк Phoenix достиг 2000000 одновременных подключений websocket. В статье разработчики демонстрируют приложение для чата, в котором симулируются 2 миллиона пользователей, а для пересылки сообщений на всех пользователей требуется 1 секунда. Они также рассказывают подробности о технических сложностях, с которыми они столкнулись в фреймворке, пытаясь добиться этого рекорда. Некоторые из изложенных в их статье идей я использовал для написания своего поста, например, назначение множественных IP, чтобы преодолеть ограничение в 65 тысяч клиентских соединений.

WhatsApp тоже достиг показателя в 2000000 подключений. К сожалению, разработчики почти не делятся подробностями. Они рассказали только о «железе» и операционной системе.

Теоретический максимум

Кто-то думает, что предел равен 216=65536, потому что это все порты, доступные по спецификации TCP. Этот предел справедлив для одного клиента создающего исходящие соединения с одной парой IP и порта. Например, мой ноутбук сможет создать только 65536 соединений с 172.217.13.174:443 (google.com:443), но, вероятно, Google заблокирует меня ещё до того, как я установлю 65 тысяч соединений. Итак, если вам нужна связь между двумя машинами с более чем 65 тысяч одновременных подключений, то клиенту нужно будет подключиться со второго IP-адреса или сервер должен сделать доступным второй порт.

У сервера, слушающего порт, каждое входящее подключение НЕ забирает порт сервера. Сервер может использовать только один порт, который он слушает. Кроме того, соединения будут поступать от нескольких IP-адресов. В лучшем случае сервер сможет прослушивать все IP-адреса, поступающие со всех портов.

Каждое TCP-подключение уникальным образом задаётся следующими параметрами:

  1. 32-битным исходного IP (IP-адресом, с которого поступает подключение)
  2. 16-битным исходным портом (портом исходного IP-адреса, с которого поступает подключение)
  3. 32-битным IP получателя (IP-адресом, к которому выполняется подключение)
  4. 16-битным портом получателя (портом IP-адреса получателя, к которому выполняется подключение)

Значит, теоретический предел, который может поддерживать сервер на одном порту — это 248, то есть около 1 квадриллиона, потому что:

  1. Сервер различает подключения от IP-адресов клиентов и исходных портов
  2. [количество исходных IP-адресов]x[количество исходных портов]
  3. 32 бита на адрес и 16 бит на порт
  4. Соединяем всё вместе: 232 x 216 = 248.
  5. Это примерно равно квадриллиону (log(248)/log(10)=14,449)!

Практический предел

Чтобы определить оптимистический практический предел, я провёл эксперименты, пытаясь открыть как можно больше TCP-соединений и заставить сервер отправлять и получать сообщение в каждом соединении. По сравнению с нагрузкой Phoenix или WhatsApp эта нагрузка совершенно непрактична, однако её проще реализовать, если вы захотите попробовать сами. Чтобы провести эксперимент, нужно справиться с тремя трудностями: операционной системой, JVM и протоколом TCP/IP.

Эксперимент

Если вам интересен исходный код, его можно изучить здесь.

Псевдокод выглядит так:

Поток 1:   открыть сокет сервера   for i from 1 to 1 000 000:     принять входящее подключение   for i from 1 to 1 000 000     отправить число i на сокет i   for i from 1 to 1 000 000     получить число j на сокете i     assert i == j  Поток 2:   for i from 1 to 1 000 000:     открыть сокет клиента серверу   for i from 1 to 1 000 000:     получить число j на сокете i     assert i == j   for i from 1 to 1 000 000     отправить число i на сокет i

Машины

В качестве машин я использовал свой Mac:

2.5 GHz Quad-Core Intel Core i7 16 GB 1600 MHz DDR3

и свой десктоп с Linux:

AMD FX(tm)-6300 Six-Core Processor 8GiB 1600 MHz

Дескрипторы файлов

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

Exception in thread "main" java.lang.ExceptionInInitializerError   at java.base/sun.nio.ch.SocketDispatcher.close(SocketDispatcher.java:70)   at java.base/sun.nio.ch.NioSocketImpl.lambda$closerFor$0(NioSocketImpl.java:1203)   at java.base/jdk.internal.ref.CleanerImpl$PhantomCleanableRef.performCleanup(CleanerImpl.java:178)   at java.base/jdk.internal.ref.PhantomCleanable.clean(PhantomCleanable.java:133)   at java.base/sun.nio.ch.NioSocketImpl.tryClose(NioSocketImpl.java:854)   at java.base/sun.nio.ch.NioSocketImpl.close(NioSocketImpl.java:906)   at java.base/java.net.SocksSocketImpl.close(SocksSocketImpl.java:562)   at java.base/java.net.Socket.close(Socket.java:1585)   at Main.main(Main.java:123) Caused by: java.io.IOException: Too many open files   at java.base/sun.nio.ch.FileDispatcherImpl.init(Native Method)   at java.base/sun.nio.ch.FileDispatcherImpl.<clinit>(FileDispatcherImpl.java:38)   ... 9 more

Каждому сокету сервера нужно два дескриптора файлов:

  1. Буфер для отправки
  2. Буфер для получения

То же относится и к клиентским подключениям. Поэтому для запуска этого эксперимента на одной машине потребуется:

  • 1000000 подключений для клиента
  • 1000000 подключений для сервера
  • По 2 дескриптора файлов на каждое подключение
  • = 4000000 дескрипторов файлов

На Mac с bigSur 11.4 увеличить ограничение на дескрипторы файлов можно так:

sudo sysctl kern.maxfiles=2000000 kern.maxfilesperproc=2000000 kern.maxfiles: 49152 -> 2000000 kern.maxfilesperproc: 24576 -> 2000000 sysctl -a | grep maxfiles kern.maxfiles: 2000000 kern.maxfilesperproc: 1000000  ulimit -Hn 2000000 ulimit -Sn 2000000

как рекомендовано в этом ответе на StackOverflow.

В Ubuntu 20.04 быстрее всего будет сделать так:

sudo su # 2^25 должно быть более чем достаточно sysctl -w fs.nr_open=33554432 fs.nr_open = 33554432 ulimit -Hn 33554432 ulimit -Sn 33554432

Пределы дескрипторов файлов Java

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

В этом ответе на StackOverflow указано решение в виде флага JVM:

-XX:-MaxFDLimit: отключает попытки установки программного ограничения на аппаратное ограничение количества открытых дескрипторов файлов. По умолчанию эта опция включена на всех платформах, но в Windows игнорируется. Отключать её стоит только в Mac OS, где её использование накладывает ограничение в 10240, что меньше, чем действительный максимум системы.

java -XX:-MaxFDLimit Main 6000


Как написано в этой цитате из документации Java, отключить флаг нужно только на Mac.
В Ubuntu мне удалось провести эксперимент без этого флага.

Исходные порты

Но эксперимент всё равно не работает. Я нашёл следующую трассировку стека:

Exception in thread "main" java.net.BindException: Can't assign requested address         at java.base/sun.nio.ch.Net.bind0(Native Method)         at java.base/sun.nio.ch.Net.bind(Net.java:555)         at java.base/sun.nio.ch.Net.bind(Net.java:544)         at java.base/sun.nio.ch.NioSocketImpl.bind(NioSocketImpl.java:643)         at java.base/java.net.DelegatingSocketImpl.bind(DelegatingSocketImpl.java:94)         at java.base/java.net.Socket.bind(Socket.java:682)         at java.base/java.net.Socket.<init>(Socket.java:506)         at java.base/java.net.Socket.<init>(Socket.java:403)         at Main.main(Main.java:137)

Последняя битва нам предстоит со спецификацией TCP/IP. На данный момент мы зафиксировали адрес сервера, порт сервера и IP-адрес клиента. При этом у нас остаётся лишь 16 бит свободы, то есть мы можем открыть только 65 тысяч соединений.

Нашему эксперименту этого совершенно недостаточно. Мы не можем поменять ни IP сервера, ни порт сервера, потому что это проблема, которую мы исследуем в этом эксперименте. Остаётся возможность изменить IP клиента, что даёт нам доступ ещё к 32 битам. В результате мы обойдём ограничение, консервативно присваивая клиентский IP-адрес для каждых 5000 клиентских подключений. Ту же технику использовали в эксперименте с Phoenix.

В bigSur 11.4 можно добавить серию фальшивых адресов замыкания на себя (loopback address) следующей командой:

for i in `seq 0 200`; do sudo ifconfig lo0 alias 10.0.0.$i/8 up  ; done 

Чтобы протестировать работу IP-адресов, их можно попинговать:

for i in `seq 0 200`; do ping -c 1 10.0.0.$i  ; done 

Чтобы удалить, используем такую команду:

for i in `seq 0 200`; do sudo ifconfig lo0 alias 10.0.0.$i  ; done 

В Ubuntu 20.04 вместо этого потребуется использовать инструмент ip:

for i in `seq 0 200`; do sudo ip addr add 10.0.0.$i/8 dev lo; done 

Чтобы удалить, используем команду:

for i in `seq 0 200`; do sudo ip addr del 10.0.0.$i/8 dev lo; done 

Результаты

На Mac мне удалось достигнуть 80000 соединений. Однако спустя несколько минут после завершения эксперимента мой бедный Mac каждый раз загадочным образом вылетал без отчётов о сбое в /Library/Logs/DiagnosticReports, поэтому я не смог диагностировать, что случилось.

Буферы TCP отправки и получения на моём Mac имеют размер 131072 байта:

sysctl net | grep tcp | grep -E '(recv)|(send)' net.inet.tcp.sendspace: 131072 net.inet.tcp.recvspace: 131072

Поэтому, возможно, это произошло из-за того, что я использовал 80000 подключений *131072 байт на буфер * 2 буфера ввода и вывода * 2 клиентских и серверных подключения байт, что равно примерно 39 ГБ виртуальной памяти. Или, может быть, Mac OS не нравится, что я использую 80000*2*2=320000 дескрипторов файлов. К сожалению, я незнаком с отладкой на Mac без отчётов о сбоях, поэтому если кто-то знает информацию по теме, напишите мне.

В Linux мне удалось достичь 840000 подключений! Однако в процессе проведения эксперимента для регистрации перемещения мыши по экрану требовалось несколько секунд. При увеличении количества подключений Linux начинал зависать и переставал реагировать.

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

Чтобы sysstat фиксировал статистику по всему оборудованию, а затем генерировал графики, я использовал такую команду:

sar -o out.840000.sar -A 1 3600 2>&1 > /dev/null  & sadf -g  out.840000.sar -- -w -r -u -n SOCK -n TCP -B -S -W > out.840000.svg

Любопытные факты:

  • MBmemfree показывал меньше всего памяти, 96 МБ
  • MBavail показывал 1587 МБ
  • MBmemused показывал всего 1602 МБ (19,6% от моих 8 ГБ)
  • MBswpused на пике показывал 1086 МБ (несмотря на то, что свободная память ещё была)
  • 1680483 сокета (840 тысяч серверных сокетов и 840 тысяч клиентских подключений плюс то, что работало на моём десктопе)
  • Спустя несколько секунд после начала эксперимента операционная система решила задействовать swap, хотя у меня ещё была память

Чтобы определить стандартный размер буферов отправки и получения в Linux, можно использовать такую команду:

# минимальное, стандартное и максимальное значения размера памяти (в байтах) cat /proc/sys/net/ipv4/tcp_rmem 4096    131072  6291456 cat /proc/sys/net/ipv4/tcp_wmem 4096    16384   4194304  sysctl net.ipv4.tcp_rmem net.ipv4.tcp_rmem = 4096        131072  6291456 sysctl net.ipv4.tcp_wmem net.ipv4.tcp_wmem = 4096        16384   4194304

Для поддержания всех подключений мне бы потребовалось 247 ГБ виртуальной памяти!

131072 байта для получения 16384 для записи (131072+16384)*2*840000 =247 ГБ виртуальной памяти

Я подозреваю, что буферы запрашивались, но поскольку из каждого нужно всего по 4 байта, использовалась лишь небольшая доля буферов. Даже если бы загрузил 1 страницу памяти, потому что мне нужно записать лишь 4 байта для записи integer в буфер:

getconf PAGESIZE 4096  Размер страницы 4096 байт (4096+4096)*2*840000 =13 ГБ


то использовалось бы 13 ГБ, задействуя 2*840000 страниц памяти. Понятия не имею, как всё это работает без сбоев! Однако мне вполне хватает 840000 одновременных подключений.

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

Итоги

  1. Фреймворку Phoenix удалось достичь 2 000 000 подключений
  2. WhatsApp удалось достичь 2 000 000 подключений
  3. Теоретический предел примерно равен 1 квадриллиону (1 000 000 000 000 000)
  4. У вас закончатся исходные порты (всего 216)
  5. Это можно исправить, добавив клиентские IP-адреса замыкания на себя
  6. У вас закончатся дескрипторы файлов
  7. Это можно исправить, изменив ограничения на дескрипторы файлов операционной системы
  8. Java тоже ограничит количество дескрипторов файлов
  9. Это можно исправить, добавив аргумент JVM -XX:MaxFDLimit
  10. На моём Mac с 16 ГБ практический предел составил 80 000 подключений
  11. На моём Linux-десктопе с 8 ГБ практический предел составил 840 000 подключений


ссылка на оригинал статьи https://habr.com/ru/post/661169/


Комментарии

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

Ваш адрес email не будет опубликован. Обязательные поля помечены *