Принципы обработки тысяч соединений в Java

Принципы обработки тысяч соединений в Java

Проблема C10k – это термин, обозначающий десять тысяч одновременно обрабатываемых соединений. Для решения проблемы часто приходится вносить изменения в настройки сетевых сокетов и Linux, следить за использованием буферов отправки и приёма TCP и очередей.

Принципы обработки тысяч соединений в Java

Когда нужно максимально продуктивно использовать процессор, следует держать количество потоков близким к числу процессоров, выделенных для приложения. Опираясь на это, следует подобрать неблокирующую логику с высоким соотношением времени обработки CPU/IO.

Иногда будет нужно перестроить код, добавив RabbitMQ или Kafka, для изменения распределённой системы, чтобы иметь возможность буферизировать таски и отделить неблокирующий код от того кода, который не получится легко переписать.

Неблокирующий код позволит:

  • Разделить приложение на две части. Например, одна часть – это REST, которая может быть реализована с помощью HTTP-сервера на основе пула потоков, а вторая часть – клиент, записывающий что-то в БД.
  • Масштабировать количество экземпляров этих двух частей по-разному, потому как очень вероятно, что нагрузка/процессор/память абсолютно разные.

На что ещё обратить внимание:

  • Храните как можно меньше потоков. Проверяйте не только серверные потоки, но и другие части приложения: клиент, драйвер БД, настройки журнала. Всегда делайте дамп потока, чтобы видеть количество потоков и их содержание. Выполняйте это под нагрузкой, иначе пулы потоков не будут полностью инициализированы. Называйте пользовательские потоки понятным образом – так будет проще сопровождать код.
  • Помните о блокировке вызовов HTTP/DB. Можно использовать реактивные клиенты, которые автоматически регистрируют для входящего ответа обратный вызов. Рассмотрите возможность использования протокола для связи service-2-service, например, RSocket.
  • Проверьте, содержит ли приложение постоянно низкое количество потоков, имеет ли оно пулы потоков и способно ли выдержать необходимую нагрузку.

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

В этом случае рассмотрите возможность использования HTTP-сервера на основе thread-pool с воркерами, где все запросы помещаются в другой поток из огромного пула для увеличения пропускной способности – другого способа нет, если мы не в состоянии избавиться от блокирующих вызовов.

Этот принцип тесно связан с темой программирования моделей для HTTP-сервера. Основная идея заключается не в привязке соединения к одному потоку, а в использовании некоторых библиотек, поддерживающих эффективный подход чтения из TCP.

Самая важная часть – это рукопожатие TCP. Вы всегда должны поддерживать keep-alive соединение. Если бы мы использовали TCP-соединение только для отправки одного сообщения, мы бы оплатили накладные расходы в размере 8 сегментов TCP ( connect and close the connection = 7 сегментов).

Подтверждение нового TCP-соединения

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

Структура взаимодействия бэклогов TCP-соединенияСтруктура взаимодействия бэклогов TCP-соединения

На рисунке бэклоги SYN и LISTEN . В SYN находятся соединения, ожидающие использования в TCP-рукопожатии. А в LISTEN – полностью инициализированные соединения, с буферами отправки и приёма. Если хотите узнать, почему нужны два бэклога, почитайте данный материал.

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

В приведённом фрагменте (конфиг Netty Server API) находятся bossEventLoopGroup и workerEventLoopGroup . В то время как workerEventLoopGroup по умолчанию создаётся с ( CPU * 2 ) потоками/циклами для выполнения IO-операций, bossEventLoopGroup содержит один поток для приёма новых соединений. Netty позволяет иметь только одну группу для обоих действий, но в этом случае принятие новых соединений может долго ждать из-за выполнения IO или более длительных операций.

Легко проверить, способен ли процесс выдержать нагрузку входящих соединений – немного модифицировав Websocket-Broadcaster для подключения 20 000 клиентов:

  • Send-Q : общий размер бэклога LISTEN .
  • Recv-Q : текущее количество подключений в бэклоге LISTEN .

Буферы Send/Receive

Когда соединение готово, наиболее проблемными частями являются буферы send/receive, которые используются для передачи байтов из приложения, в базовый сетевой стек. Размер этих буферов можно настраивать:

Дополнительные сведения о параметрах сокетов в Java прочитайте в официальном хелпе StandardSocketOptions. Новые версии Linux способны автоматически настраивать буферы для достижения оптимального размера в текущей нагрузке с помощью TCP Congestion Window .

Прочитайте про TCP Buffer Sizing. Большие буферы могут привести к утечкам памяти, а маленькие – задушат приложение т. к. не будет места для передачи байтов в/из сетевого стека.

Почему кэширование потока это плохо?

Java Thread – «дорогой» объект, ведь он сопоставлен один в один с потоком ядра. В Java мы можем ограничить размер стека потока с помощью опции -Xss , которая по умолчанию установлена в 1 Мб. Это означает, что один поток занимает 1 Мю виртуальной памяти, а не Committed Memory. Если не используются жадные фреймворки или рекурсия, размер потока составляет 200-300 кБ. Этот вид памяти принадлежит к Native Memory, а все моменты можно отследить с помощью Native Memory Tracking.

Другая проблема с большим количеством потоков – огромный Root Set. Например, есть 4 процессора и 200 потоков. В этом случае вы можете запустить только 4 потока, но если все 200 потоков уже заняты обработкой, вы заплатите большую цену за объекты, расположенные в куче, так как данный поток ждёт свободное процессорное время. Все объекты, которые уже были выделены и всё ещё используются, не будут затронуты сборщиком мусора.

Почему Root Set – это такая большая проблема?

Принципы обработки тысяч соединений в Java

Красные точки могут обозначать любой момент времени, когда работают только 4 потока, а остальные просто ждут в очереди. Область Completeness of the task не означает, что все объекты, выделенные до сих пор, все еще живы, они уже могли быть собраны в мусор или ожидают следующего цикла сборщика мусора. Что тут не так?

  • Слишком большой Live Set: каждый поток сохраняет выделенные живые объекты в куче, который просто ждёт. Этот момент нужно держать в памяти, когда мы определяем размер кучи.
  • Большие паузы GC из-за большего Root Set, усложняющего работу сборщика мусора. Современные сборщики начинают с идентификации Root Set (Snapshot-At-The-Beginning – работа с живыми потоками), а затем проходят через граф, чтобы найти текущий Live Set. Чем больше Root Set, тем больше работы для GC.
  • Движение к Old Generation: большой Live Set также будет влиять на время, в течение которого данный объект будет считаться живым. Это увеличивает вероятность того, что объект будет переведен в Old Generation, даже если поток, хранящий этот объект, провел большую часть своего времени вне ЦП.

Принципы обработки тысяч соединений в Java

Для написания приложения, которое будет находиться под большой нагрузкой, необходимо подумать о выделении всех объектов и не позволять JVM тратить ни одного байта. В этом поможет ByteBuffers .

Класс ByteBuffer имеет две опции: HeapByteBuffer и DirectByteBuffer . Ключевая польза DirectByteBuffers – возможность передавать напрямую в нативные функции ОС выполнение I/O. Другими словами, когда вы работаете с I/O в Java, вы передаете ссылку на DirectByteBuffer (со смещением и длиной).

Рассмотрим на примере. У вас есть 10k соединений, и вы хотите им передать одно и то же строковое значение. Нет никакой причины передавать строку 10k раз или еще хуже, генерировать новый объект String для каждого соединения, засоряя кучу одним и тем же массивом байтов. Вместо этого мы можем создать собственный DirectByteBuffer и предоставить его всем соединениям, а затем позволить им передать все в ОС через JVM.

Есть одно но – DirectByteBuffer очень «дорого» выделять. Поэтому в JDK каждый поток, работающий с I/O, кэширует один DirectByteBuffer для внутреннего использования. Почитайте об утечках памяти в ByteBuffer для большей наглядности.

Но зачем тогда нужен HeapByteBuffer , если его нужно преобразовать в DirectByteBuffer для возможности записи в ОС? HeapByteBuffer гораздо дешевле обходится. Если мы вернемся в пример выше, то мы могли бы исключить первый шаг и не делать это 10k-раз. Тогда можно рассчитывать на автоматический механизм кэширования DirectByteBuffer для каждого потока внутри JDK.

Ознакомьтесь с полезным набором утилит для работы с TCP: BCC Tools и bpftrace.

Используя bpftrace, можно получить быстрый результат для исследования возможной проблемы. Пример демонстрирует, как socketio-pid.bt подсчитывает количество переданных байтов на основе PID:

Видим пять потоков (server-io-x) и каждый поток висит в цикле событий. Каждый цикл имеет одного подключенного клиента, и приложение транслирует случайно сгенерированное строковое сообщение всем подключенным клиентам с помощью протокола Websocket.

  • @bytes – сумма r/w байтов;
  • @io – общее количество операций r/w.

Производительность приложения рано или поздно упирается в компромисс между пропускной способностью и задержкой. Предположим, что у нас есть Netty и WebSocket-сервер, который отправляет сообщения подключенным клиентам. Действительно ли нам нужно отправить сообщение как можно скорее? Или можно подождать, создать пакет из пяти сообщений и отправить их вместе?

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

Если у вас стоит Java 14, включающая в себя функцию Java Flight Recorder Streaming, то вы можете увидеть, как действует Netty в данном случае:

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

Здесь представлены две концепции, которые в настоящее время становятся сильными вариантами обработки большого количества клиентов/запросов, даже если они явно не связаны с решением проблемы C10k.

GraalVM Native Image – для экономии ресурсов

GraalVM – мощная штука, работающая на AOT-компиляторе и включающая в себя фреймворк SubstrateVM. В двух словах это означает, что Graal используется во время сборки для генерации машинного кода без каких-либо данных профилирования.

Он будет генерировать автономный двоичный файл без каких-либо неиспользуемых классов/методов, внутренних структур, таких как JIT-структуры данных. Такая оптимизация гарантирует, что мы сможем запускать наше приложение с гораздо меньшим объемом памяти, и даже если приложение имеет менее эффективный код, мы можем в конечном итоге получить лучшее соотношение между потребляемой памятью и общим количеством обработанных запросов. Это означает, что мы можем развернуть несколько экземпляров приложения с ещё большей пропускной способностью и меньшим количеством ресурсов.

Проект Loom – устранение боли от блокировки вызовов

Вы, наверное, слышали о fibers/green threads/goroutines. Все эти термины означают одно – избежать планирования потоков в ядре, дилегируя ответственность в пространство пользователя. Типичным примером является то, что у нас есть много блокирующих вызовов, каждый запрос к приложению заканчивается в JDBC/HTTP/ . Далее происходит вызов. Нам нужно заблокировать текущий поток Java и ждать, пока не вернется ответ.

Вместо этого мы можем использовать Fibers из Project Loom. Это гарантирует, что блокирующий вызов фактически не блокирует поток Java, а только текущий Fiber. Поэтому мы можем запланировать новый Fiber на текущей запущенной Java, а затем вернуться к исходному Fiber, когда блокирующий вызов будет выполнен. Результатом будет возможность обрабатывать все запросы, даже с очень ограниченным числом потоков Java, потому что Fibers «почти бесплатны».

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

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