[Перевод] Java и низкая задержка

turbksmw-ttbyw5o_dmbqwatl-k.png

Источник

Я уже сбился со счёта, сколько раз мне говорили, что Java — неподходящий язык для разработки приложений, основным требованием к которым является высокая производительность. Обычно первым делом я прошу уточнить, что подразумевается под словом «производительность», поскольку две самые популярные метрики — пропускная способность и задержка — иногда конфликтуют друг с другом, а способы оптимизации одной из них существенно ухудшают вторую.

Существуют методики разработки Java-приложений, которые соответствуют требованиям к производительности (или даже превосходят их) приложений, созданных на языках, традиционно применяемых для этой цели. Однако даже этого может быть недостаточно, чтобы обеспечить наилучшую производительность с точки зрения задержек. Java-приложениям по-прежнему приходится полагаться на операционную систему в вопросе предоставления доступа к оборудованию. Обычно чувствительные к задержке приложения (часто называемые «приложениями реального времени») лучше всего работают, когда имеют практически прямой доступ к оборудованию, то же самое относится и к Java. В этой статье я познакомлю вас с методиками, которые можно применять, когда мы хотим, чтобы приложения максимально эффективно задействовали системные ресурсы.
Язык Java проектировался с целью возможности портирования программ на двоичном уровне для широкого диапазона оборудования и системных архитектур. Это было реализовано при помощи виртуальной машины — абстрактной модели платформы выполнения — и выполнения результата работы компилятора исходного кода Java. Смысл заключался в том, что для перехода на другой тип аппаратной платформы достаточно будет только портировать виртуальную машину. Приложения и библиотеки должны работать без изменений (в соответствии с девизом «write once run everywhere»).

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

За прошедшие годы виртуальная машина Java эволюционировала в чрезвычайно сложную платформу, способную генерировать машинный код в среде выполнения из байт-кода Java и оптимизировать этот код на основе динамически собираемых метрик. На это не способны статически компилируемые языки наподобие C++, поскольку у них отсутствует необходимая информация среды выполнения. Аккуратный подход к выбору структур данных и алгоритмов может минимизировать или даже устранить потребность в сборке мусора — одного из самых очевидных аспектов окружения среды выполнения Java, мешающего обеспечивать стабильные показатели задержки.

Но в конечном итоге, виртуальная машина Java является именно виртуальной, то есть она должна выполняться поверх операционной системы, управляющей её доступом к аппаратной платформе. Какой бы ни была операционная система: Linux (наверно, самая широко используемая ОС в серверных окружениях), Windows или какая-то другая, проблема всё равно остаётся.

«Проблема» Linux


В течение многих лет Linux эволюционировала как член семейства операционных систем Unix. Первая версия Unix была разработана в конце 1960-х; поначалу она развивалась и завоёвывала популярность в научных и исследовательских кругах, а затем под разными обличьями и в мире коммерции. Linux стал доминирующим вариантом Unix, хотя по-прежнему сохраняет в себе многие особенности своего предка. Сегодня с появлением сред выполнения на основе контейнеров и облачных технологий его доминирование стало почти абсолютным.

Однако с точки зрения приложений реального времени (то есть чувствительных к задержке) Linux/Unix имеет проблемы. В основном они проистекают из того, что Unix проектировался как система с разделением времени. Исходной аппаратной платформой для него были миникомпьютеры, на которых одновременно работало множество пользователей. У каждого из пользователей были собственные задачи, и Unix гарантировал, что все они получат «справедливую долю» ресурсов компьютера.

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

Однако в процессе эволюции компьютеров с несколькими процессорами потребовались серьёзные инженерные изменения в сердце операционной системы Unix, которые позволили бы использовать эти ядра эффективным образом. Впрочем, подход оставался тем же — интерактивным задачам всегда отдавался больший приоритет, чем задачам с интенсивными вычислениями. Благодаря наличию нескольких ядер, в конечном итоге это всё повышало общую производительность.

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

Как можно решать эти проблемы?


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

▍ Среда выполнения Java


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

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

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

В распространённых архитектурах современных приложений содержатся способы связи между компонентами, обычно реализуемые при помощи сообщений. При обмене данными сообщения сериализуются и десериализуются из стандартных форматов наподобие JSON или YAML, а предоставляющие эту функциональность библиотеки часто предоставляют возможность высокоуровневого управления распределением объектов. При аккуратном проектировании можно выбрать библиотеки, нацеленные на минимизацию создания новых объектов Java, а следовательно, положительно влияющие на производительность.

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

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

▍ Linux


Справедливо будет заметить, что существовали и варианты Unix «реального времени», предоставлявшие специализированным приложениям другие среды исполнения. Хотя в общем случае они были нишевыми продуктами, сегодня многие из подобных подходов и функций доступны в популярных дистрибутивах Unix и Linux.

Функции для минимизации задержек в общем случае разделяются на две категории: управление памятью и планирование потоков.

Вся память в процессе Linux, в том числе куча Java со сборкой мусора, подвергается временной «откачке» на диск, чтобы другие процессы могли использовать ОЗУ для своих целей, прежде чем возникнет потребность возврата памяти. Это происходит абсолютно прозрачно для процесса, а разница во времени доступа к данным в памяти и к данным на накопителе может различаться на несколько порядков величин. Разумеется, память вне кучи подвержена такому же поведению.

Однако современные системы Unix и Linux позволяют помечать области памяти, чтобы их игнорировала операционная система в процессе поиска областей, которые можно забрать у процесса. Это означает, что для таких областей памяти в этом процессе время доступа к памяти будет предсказуемым. Стоит также сказать, что у активно выполняемого Java-приложения частота доступа к памяти процесса снижает вероятность откачки этой памяти, но риск всё равно присутствует.

Такое резервирование памяти процесса будет означать, что другим процессам останется меньше памяти, от чего они могут страдать, однако в мире приложений «реального времени» всегда нужно немного эгоизма!

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

Потоки в Java-программе, как и их аналоги в других приложениях и даже в задачах операционной системы, имеют доступ к процессору, управление которым выполняется компонентом операционной системы под названием «планировщик». Планировщик имеет набор политик, который он использует, чтобы решать, какие нужно выбирать потоки, требующие доступа к процессору (они называются Runnable thread) — обычно количество Runnable thread больше, чем количество процессоров.

Как говорилось выше, традиционные политики планировщиков Unix/Linux отдают предпочтение интерактивным, а не вычислительноёмким потокам. Если мы стремимся выполнять чувствительные к задержкам приложения, то это не идёт нам на пользу — нужно, чтобы наши потоки каким-то образом получили больший приоритет, чем потоки, нечувствительные к задержкам.

Современные системы Unix/Linux предоставляют альтернативные политики планирования, способные обеспечить такие возможности, позволяя зафиксировать приоритеты планирования потоков на высоких уровнях, чтобы они всегда забирали ресурсы процессора у других потоков, когда они являются Runnable, благодаря чему они могут быстрее реагировать на события.

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

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

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

▍ Заключение


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

❒ Ресурсы


Подробнее о некоторых из тем, рассмотренных в статье, можно прочитать в этой книге.

RUVDS | Community в telegram и уютный чат

sz7jpfj8i1pa6ocj-eia09dev4q.png

© Habrahabr.ru