четверг, 14 ноября 2013 г.

Сравнение производительности механизмов ввода-вывода в Java: классического (блокирующего), неблокирующего и асинхронного

Не всегда при обеспечении взаимодействия нескольких программ можно полагаться на существующие транспортные механизмы. Да, у нас есть вся мощь веб-сервисов, при использовании серверов приложений нам доступна технология Java EE Connector Architecture (JCA), при взаимодействии с СУБД можно использовать JDBC, а в случае асинхронного взаимодействия можно использовать Java Message Service (JMS). Однако, не смотря на такое обилие технологий, бывает нужно обеспечить нетривиальное взаимодействие с системами, используя сокеты и самостоятельно реализуя протокол прикладного уровня. В данной заметке приведены результаты сравнения производительности трех существующих на данный момент в Java механизмов обеспечения сетевого взаимодействия: классического блокирующего, неблокирующего и асинхронного.

Краткая характеристика существующих подходов к организации ввода-вывода


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

Блокирующий ввод-вывод (IO). Данный механизм взаимодействия появился в Java самым первым и долгое время оставался единственным возможным подходом к обеспечению сетевого взаимодействия. Суть его заключается в том, что со стороны сервера и клиента создается сокет (со стороны клиента явно, со стороны сервера - в ответ на запрос соединения со стороны клиента). С каждым сокетом связаны два потока (InputStream и OutputStream): один служит для отправки сообщений, другой - для их приема. При этом все операции с данными потоками являются блокирующими, т.е. исполнение команд прерывается на время выполнения данных операций. Предположим, нам необходимо разработать индексатор сайтов (Web Crowler), являющийся частью поискового робота. Обращение к одному сайту занимает 300 мс. Если нам нужно проиндексировать 1000 сайтов, то в блокирующем режиме это займет 300 x 1000 мс, т.е. 300 секунд.

Путем ускорения работы приложений при использовании IO является многопоточность - задача делится на части, каждая из которых выполняется своим потоком управления. Например, чтобы обойти 1000 сайтов можно разбить их на четыре группы по 250 сайтов и обойти сайты из каждой группы параллельно. Самой популярной моделью взаимодействия является создание отдельного потока на каждое подключение. Таким образом все соединения обслуживаются параллельно и не мешают друг-другу. Недостатком такого подхода является линейный рост числа потоков и объема требуемой памяти при увеличении числа обслуживаемых соединений. Если потоков будет создано очень много (десятки тысяч), то операционная система сервера будет занята переключенем контекстов потоков, а на полезную работу времени может не остаться.

Неблокирующий ввод-вывод (NIO). Для решения проблем с блокирующим вводом-выводом был придуман механизм, основанный на мультиплексировании каналов. Данный механизм появился в Java 1.4.2 и был назван New IO (NIO). Суть механизма в следующем: существует мультиплексор (в терминах Java называемый селектором, java.nio.channels.Selector), который в одном потоке, последовательно, производит опрос каналов (в случае сетевого взаимодействия реализуемых классами java.nio.channels.SocketChannel и java.nio.channels.ServerSocketChannel). В результате каждого опроса селектор возвращает идентификаторы каналов, готовых к выполнению операций ввода-вывода (т.е. канал соединился с удаленной системой и в него теперь можно отправлять запрос или, наоборот, удаленная система что-то записала в канал и из него теперь эти данные можно читать). Такие идентификаторы называются "ключами" (java.nio.channels.SelectionKey). Каждый ключ содержит информацию о том, к выполнению какой операции готов канал. Задача приложения - в цикле обойти все ключи и выполнить соответствующие операции.

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

Асинхронный ввод-вывод (AIO, NIO.2). NIO является хорошим средством масштабирования приложений, активно использующих сетевые соединения, но данный подход имеет серьезный недостаток. Поток NIO вынужден явно опрашивать каналы (по-другому это называется "полинг"). При этом, если готовых к осуществлению взаимодействия каналов нет, то данный поток блокируется. Не будет ли лучше, если инициатором взаимодействия с каналами будет не приложение, а сама операционная система? Именно операционная система знает какие каналы готовы к осуществлению взаимодействия, ведь именно она осуществляет обслуживание сетевых карт, портов и прочих механизмов ввода-вывода. Как говорится, ей и карты в руки.

В Java 7 появились механизмы для обеспечения асинхронного сетевого взаимодействия. Это каналы java.nio.channels.AsynchronousSocketChannel и java.nio.channels.AsynchronousServerSocketChannel. Данные каналы содержат методы для неблокирующего установления соединения, приема соединения, записи и чтения. Каждый метод имеет два варианта: один реализован с использованием java.util.concurrent.Future - метод запускает требуемую операцию в отдельном потоке и сразу же возвращает в качестве результата объект класса java.util.concurrent.Future. Для получения результата операции необходимо вызвать Future#get(). Т.к. операция уже запущена в отдельном потоке, то она к моменту вызова Future#get() может быть уже выполнена, тогда метод get() сразу же вернет результат. В противном случае данный метод блокирует поток, в котором он вызван, до завершения операции. Другой вариант реализации асинхронных методов сетевого взаимодействия основан на использовании обработчиков обратного вызова - callback. Данный вариант реализует паттерн Proactor. Суть в следующем - каждый метод, например write, принимает в качестве параметра обработчик завершения - реализацию интерфейса java.nio.channels.CompletionHandler. Метод запускает требуемую операцию в отдельном потоке и передает управление дальше. Когда требуемая операция полностью выполняется, срабатывает один из методов переданного при старте операции обработчика завершения. Основное отличие между асинхронным и неблокирующим вводом-выводом заключается в том, что асинхронный ввод-вывод работает в многопоточной среде: операции выполняются не в тех потоках, из которых были запущены. Операции неблокирующего ввода-вывода выполняются в одном потоке путем мультиплексирования каналов.


Сравнение производительности операций ввода-вывода без отвлечения ресурсов процессора


После понимания сути различных подходов к организации ввода-вывода можно начать сравнивать их с точки зрения производительности. Для сравнения был разработан "индексатор страниц", который отправлять 1000 запросов по протоколу HTTP на специальный сервер. На сервере эмулируется формирование ответа с задержкой 300 мс. Это нужно, чтобы продемонстрировать потери на ввод-вывод.

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

Тестирование производится на следующей конфигурации: 2-х ядерный процессор Intel i5, 8 Гб ОЗУ, компьютер работает под управлением Windows 7 x64, используется Oracle Java 1.7.0_u40 x64. Серверная часть находится на другой машине и запущена на сервере приложений WebLogic 10g. Связь между клиентом и сервером осуществляется по 100 Мбит/с Ethernet.

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

Результаты тестирования:

Задержка при формировании ответа 300 мс, размер ответа - 1 Кб


Задержка при формировании ответа 300 мс, размер ответа - 50 Кб


Задержка при формировании ответа 50 мс, размер ответа - 500 Кб


(для снижения времени выполнения замеров задержка при формировании ответов на сервере снижена до 50мс. Измерения при IO в 1000 потоков прерваны, т.к. надежность данного режима крайне низка - наблюдаются разрывы соединений и превышение таймаутов операций ввода-вывода. Асинхронный ввод-вывод не тестировался так же из-за его низкой надежности, см. ниже.)

Прежде всего стоит развенчать популярный миф, что NIO быстрее IO. Этот миф очень популярен, особо в среде начинающих программистов. Даже при обработке небольших ответов, размером в 1 кб, модель "по потоку на соединение" оказалась самой быстрой, на 10% быстрее NIO, на 5% быстрее AIO. Хотя данные числа и не превышают погрешности измерений. С ростом же интенсивности ввода вывода видно, что IO уверенно обгоняет NIO, работая в 64 потока на 26% быстрее при обработке 50-ти килобайтных ответов и на 40% быстрее при приеме 500-т килобайтных сообщений. Так же следует учесть, что программировать в старом-добром блокирующем стиле проще. Если говорить о ресурсах, то расход процессора в блокирующем режиме даже на 1000 потоков составляет единицы процентов, в то время как в режиме NIO расход процессора составляет 20-30%.

В статье Java IO Faster Than NIO – Old is New Again! приведена ссылка на сравнение серверов, написанных по модели "поток на соединение" и с использованием NIO. IO показал производительность на 25% больше чем NIO. Вообще статья и комментарии к ней довольно интересны.

Отдельно хочется сказать про AIO. При работе с асинхронными сокетами мы можем управлять пулом потоков, в которых будут обрабатываться операции. Если процессор не отвлекается на вычислительные задачи, а полностью обслуживает только управление потоками и ввод-вывод, то в принципе не сильно важно сколько потоков в пуле для асинхронных сокетов, но чтобы система не "залипала", лучше если их будет 2 - 4.

Стоит отметить, что при росте объема принимаемых/передаваемых данных при одновременном осуществлении тысячи подключений, AIO как минимум на Windows начинает вести себя очень ненадежно. При считывании с сервера 500-т килобайтных ответов появляются ошибки соединения: java.io.IOException: Превышен таймаут семафора. Увеличением числа потоков в пуле решить данную проблему не удается.

Сравнение производительности операций ввода-вывода при необходимости в обработке данных


Программа, выполняющая только операции ввода-вывода, интересна, но гораздо интереснее проанализировать поведение программы, обрабатывающей полученные данные. Пронаблюдаем за эмуляцией ресурсоемких для процессора задач, таких как разбор XML, индексация HTML, поиск с помощью регулярных выражений, проверка ЭЦП, шифрование и т.д. Встроим во все примеры после считывания сообщений эмуляцию их обработки с помощью математических операций. Участок обработки данных при выполнении операций в один поток будет занимать 5 - 8 мс. Размер считываемого с сервера сообщения составляет 1 Кб, ответы сервер формирует с задержкой в 300 мс.

Результат измерений:


"Победителем" при таком сравнении является асинхронный ввод-вывод, при котором данные обрабатываются в один поток. Производительность AIO выше чем у модели "поток на соединение" на 46%. Стоит отметить, что при увеличении числа потоков, не блокируемых на вводе-выводе, нагрузка на процессор пропорционально растет, а вот скорость выполнения математических операций процессором снижается. Соответственно, тот блок операций, который в один поток выполнялся 8 мс., при загруженности процессора двумя потоками выполняется за 10 - 20 мс., четырьмя потоками - 40-60 мс., а шестнадцатью потоками - уже 100 - 256 мс, что становится сопоставимым с длительностью операций ввода-вывода!

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

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

Решением проблемы с блокировками является отложенная обработка данных. Данные не обрабатываются по месту получения из канала, а помещаются в неблокирующую очередь, например в экземпляр класса java.util.concurrent.ConcurrentLinkedQueue, появившегося в Java 5. Обрабатываются же данные другим потоком (одним или несколькими). В Java 6, до появления AIO, данный режим является самым быстрым, обгоняя на 40% модель "поток на соединение". При увеличении числа потоков-обработчиков данных, время обработки растет, т.к. данные потоки не блокируются и полностью нагружают процессор. Уже при пяти потоках программа намертво блокирует компьютер так, что работать с ним становится невозможно.

Исходные коды программ




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

Выводы


Конечно, приведенные микробенчмарки далеки от совершенства, например в них не производится прогрев программы перед замером времени, а так же автоматический подсчет среднего арифметического результатов замеров. Но с другой стороны флуктуации в загруженности сети, сервера и тестовой машины окажут влияние на результат гораздо больший чем JIT-компилятор. Скачки из-за качества сети составляли при измерениях единицы секунд, такие "вылеты" приходилось исключать из результатов измерений. Порядок полученных результатов является верным, а показанный в таблицах разброс помогает понять, что о некоторых вещах нельзя говорить однозначно. Например, в случае отсутствия обработки результатов модель "поток на соединение" оказалась на 5% быстрее AIO. Значит ли это, что всегда нужно использовать модель "поток на соединение"? Вовсе нет, хотя данная модель и является наиболее легкой в реализации. Если мы добавляем обработку данных и наша программа начинает эксплуатировать процессор, то модель "поток на соединение" оказывается на десятки процентов медленнее других подходов. Такая разница следует уже из принципиальных различий в моделях организации ввода-вывода.

Каковы же области применения у каждого подхода? На основании полученных данных можно сформулировать следующие принципы. Если ваше приложение должно обеспечить одновременное обслуживание десятков тысяч подключений, при этом каждому клиенту отправляется небольшая порция данных, т.е. речь идет об онлайн-игре, чат-сервере, P2P-клиенте, то модель NIO и появившаяся в Java 7 модель AIO являются лучшими кандидатами.

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

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

Понравилось сообщение - подпишитесь на блог

9 комментариев:

Dmitry Rygalov комментирует...

Спасибо за статью, очень информативно)
У меня вопрос возможно ли смешение подходов? то есть переход от одного подхода к другому? к примеру с возрастающему объему данных мы перейдем от одного к другому...
я имею в виду "безболезненную" передачу обрабатываемых конектов из одного потока в другой, а не завершение старых и перенаправление новых конектов на другой подход...

Pavel Samolisov комментирует...

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

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

Eugene Schava комментирует...

Исправьте плиз опечатку - Feature -> Future

Pavel Samolisov комментирует...

Спасибо за замечание, исправил.

Sergey Kisel комментирует...

опечатка в статье - java.util.concurrent.Feature -> java.util.concurrent.Future

Pavel Samolisov комментирует...

Добавил еще два сравнения: NIO, AIO и IO при чтении 50-ти килобайтных ответов и NIO, IO при чтении 500-т килобайтных ответов, результаты полностью подтверждают изложенное в выводах.

ilyas комментирует...

Крутая статья. Спасибо.

Pavel Samolisov комментирует...

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

Вадим Викулин комментирует...

спасибо!

Отправить комментарий

Любой Ваш комментарий важен для меня, однако, помните, что действует предмодерация. Давайте уважать друг друга!