Не всегда при обеспечении взаимодействия нескольких программ можно полагаться на существующие транспортные механизмы. Да, у нас есть вся мощь веб-сервисов, при использовании серверов приложений нам доступна технология
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. Метод запускает требуемую операцию в отдельном потоке и передает управление дальше. Когда требуемая операция полностью выполняется, срабатывает один из методов переданного при старте операции обработчика завершения. Основное отличие между асинхронным и неблокирующим вводом-выводом заключается в том, что асинхронный ввод-вывод работает в многопоточной среде: операции выполняются не в тех потоках, из которых были запущены. Операции неблокирующего ввода-вывода выполняются в одном потоке путем мультиплексирования каналов.