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

В операционной системе каждый процесс имеет собственную память, ресурсы, атрибуты (мандат) доступа и набор потоков выполнения, созданных в рамках данного процесса.
Все потоки работают с общей памятью, но имеют собственный стек вызова и контекст выполнения. По умолчанию, размер стека потока составляет 8 МБ.
Теперь взглянем на статусную модель потока в операционной системе:

При выполнении IO‑вызова поток переходит из состояния исполнения (RUNNING) в состояние ожидания (WAITNG). Когда данные получены — поток переходит в состояние READY и может быть снова поставлен на исполнение планировщиком операционной системы.

Вспомним характерное время выполнения типовых операций:
Операция |
Характерное время |
выполнить типичную инструкцию на процессоре |
1/1 000 000 000 секунды = 1 наносекунда |
извлечение данных из кэш‑памяти L1 |
0,5 наносекунды |
неправильное предсказание ветви |
5 наносекунд |
извлечение данных из кэш‑памяти второго уровня |
7 наносекунд |
блокировка / разблокировка мьютекса |
25 наносекунд |
извлечение из основной памяти |
100 наносекунд |
отправить 2КБ по сети со скоростью 1 Гбит/с |
20 000 наносекунд |
чтение 1 МБ с SSD |
300 000 наносекунд |
переключение потока выполнения |
1000 — 10 000 наносекунд |
Из таблицы видно, что характерное время выполнения I/O‑операций (работа с сетью или дисками) в 1000–10000 раз медленее, чем выполение операций на CPU. Причем время переключения CPU c одного потока выполенения на другой сравнимо с временем выполнения I/O‑операций.
При реализации клиент‑серверных приложений на блокирующих I/O‑фреймоврках, как правило, устанавливают число потоков в районе 200–400. Большее количество потоков приводит к просадкам в производительности из‑за расходов на переключение контекста. Малое же количество рабочих потоков приведет к тому, что потоки рано или поздно упрутся в выполнение I/O‑операций, и обслуживание новых клиентов прекратится или замедлится, хотя сам CPU будет недозагружен.
Возник вопрос — а можно ли сделать так, что бы процесс не блокировался при вызове I/O‑операции, а просто её инициировал и продолжал работу, время от времени проверяя, не готовы ли данные.

При честной реализации такой схемы (когда мы не хотим отвлекать CPU), возникает несколько важных моментов, оказывающих влияние как на решение задачи на уровне hardware, так и на дизайн рантайма прикладных языков:
async I/O устройство должно уметь работать с оперативной памятью (данные нужно откуда‑то считать или куда‑то записать);
доступ к памяти процессов ограничен, поэтому буфер async I/O выделяется за пределами основной памяти процесса;
в теории можно использовать один поток и для работы с async I/O и для последующей бизнес‑обработки, но в этом случае мы как минимум не сможем использовать все ресурсы CPU; поэтому выделяют один поток на работу с async I/O и пул потоков на бизнес‑обработку.
Для реализации схемы работы async I/O устройств с оперативной памятью потребовалась аппаратная поддержка — связка блока CPU Memory Management Unit (MMU) и появление Direct Memory Access (DMA) контроллеров.
Прим. Если создать буфер как DirectBuffer — то JVM будет работать с буфером напрямую без дополнительного копирования данных. Технология прямого доступа JVM к буферам обмена(расположенным в off‑heap memory) и получила название ZeroCopy. В противном случае данные будут копироваться между буфером в оперативной памяти и памятью управляемой JVM.

Физический поток, который будет выполнять код после получения данных, как правило, отличается от потока, который запросил I/O операцию. После получения данных нужно как‑то эти данные обработать. Поэтому возникает потребность передавать не только буфер обмена, но и callback‑метод по обработке полученных данных. Буфер и callback могут передаваться в прикладном коде явно, а могут быть спрятаны где‑то на уровне в runtime.

Отдельно нужно напомнить про имеющиеся ограничения в поддержке работы с файловой системой через асинхронное api в операционных системах. Такие ограничения приводят, в частности, к тому, что java NIO не поддерживает неблокирующие операции с файлами, а планировщик Go по разному обрабатывает сетевые вызовы и обращения к диску.
В Linux системах Java NIO взаимодействует с ядром операционной системы через подсистему асинхронного ввода‑вывода — вызов epoll. Подобный подход используется и в GoLang.
Использование подсистемы асинхронного ввода‑вывода не требует наличия DMA‑устройств, и ядро вполне может подобное поведение имитировать. Тем не менее, для достижения наибольшего эффекта лучше все же иметь аппаратную поддержку.
Асинхронность в Kotlin
Проблема, которую решает асинхронность в Kotlin, другого рода.
Прим. Далее буду пересказывать доклад Романа Елизарова (ссылка в сносках), рекомендую послушать оригинал. Но если ходить по ссылкам лень — то можно и почитать.
Предположим, у нас есть какая‑то бизнес‑логика:
fun processTicket(ticket: Ticket): ProcessResponse { val flyInfo = service1.getFlywInfo(ticket) val userData = service2.getUserData(ticket, flyInfo) if (userData.isSpecial) { val userDataExtra = service3.getUserExtraData(userData) return computeExtraResponse(ticket, userData, userDataExtra) } else { return computeResponse(ticket, userData) } }
Допустим, что в service3 мы ходим только каждый десятый раз, но этот сервис очень медленный.

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

Мало того, что начинают нервничать клиенты, но мы можем ещё и service3 положить!!!
Тогда попробуем спроектировать наше приложение так, чтобы в service3 мы ходили через ограниченный пул потоков.

Прекрасная идея, но давайте посмотрим, во что превращается наш код (код условный):
fun processTicket(ticket: Ticket): ProcessResponse { val flyInfo = service1.getFlywInfo(ticket) val userData = service2.getUserData(ticket, flyInfo) if (userData.isSpecial) { val result = Service3Pool.sumbit( () -> service3.getUserExtraData(userData) ).get() return result } else { return computeResponse(ticket, userData) } }
Видим, что теперь, вместо понятного кода, решающего бизнес‑задачу, появление асинхронщины приводит к загромождению кода вспомогательными конструкциями.
Давайте ещё посмотрим примеры:
// Пример функции на coroutines fun processTicket(ticket: Ticket): ProcessResult { val flyInfo = service1.getFlywInfo(ticket) val userData = service2.getUserData(ticket, flyInfo) return computeResponse(ticket, userData) }
Когда код синхронный — все ясно и понятно. При попытке реализовать асинхронное обращение к сервисам на библиотеках типа reactor будет что‑то вроде:
// Пример функции на RX - нагромождение лишних функций fun processTicket(ticket: Ticket): Future<ProcessResult> = service1.getFlywInfoAsync(ticket) .flatMap(flyInfo -> service2.getUserDataAsync(ticket, flywInfo)) .map(userData -> computeResponse(ticket, userData)))
При использовании callback будет что‑то вроде:
// Пример с использованием Callback - логика перевернута fun processTicket(ticket: Ticket): Callback<ProcessResult> = computeResponse(ticket, { service2.getUserData(ticket, { service1.getFlywInfo(ticket) }) })
Даже на таких простых примерах видно, что асинхронщина приводит к загромождению кода вспомогательными конструкциями или к «выворачиванию» логики.
То есть, вместо решения бизнес‑задачи мы начинаем тратить большую часть нашего времени на решение технических вопросов.
Ну а теперь посмотрим на код с корутинами:
// Пример функции на coroutines suspend fun processTicket(ticket: Ticket): ProcessResult { ➡ val flyInfo = service1.getFlywInfo(ticket) ➡ val userData = service2.getUserData(ticket, flyInfo) if (userData.isSpecial) { ➡ val userDataExtra = service3.getUserExtraData(userData) } return computeResponse(ticket, userData) }
За исключением модификатора suspend (стрелочками IDE подсвечивает вызовы suspend‑функций) код выглядит синхронным!!! Задачи по переключению пулов решаются компилятором и kotlin‑rutime.
Подчеркну ещё раз: kotlin coroutines — это способ разработки асинхронного приложения, при котором разработчик сосредотачивается на бизнес-логике и пишет код в синхронном стиле.
Прим. При разработке GUI‑приложений всегда важно освободить основной UI‑поток. Благодаря своей мощи kotlin и стал особенно популярен в android‑разработке.
Вывод
Асинхронность в NIO — это возможность использовать асинхронное API операционной системы.
Асинхронность в Kotlin — это прозрачный способ переключения между пулами потоков, при котором разработчик сосредотачивается на бизнес‑логике, а всю техническую обвязку берет на себя компилятор и runtime.
Асинхронщина в Kotlin никак не связана с асинхронщиной в NIO. Если ваша библиотека или фреймворк будут синхронными, потоки в пуле, в котором происходи I/O вызов, всё так же будут блокироваться. Зато Kotlin предлагает прекрасный способ вынести такие синхронные вызовы в отдельный пул, взяв на себя логику переключения потоков.
Поэтому для достижения максимального эффекта используем:
фреймворки на базе nio (netty);
библиотеки для работы с сервисами на базе nio;
ktor (CIO);
все legacy синхронные IO‑библиотеки выносим в отдельный пул
и сосредотачиваемся на бизнес‑логике!!
Ioanna
Различие только в suspend. Стрелочки - элемент интерфейса IDE, не кода.
st_merzlyakov Автор
Казалось, что так и написал. Но скорректирую формулировку чтобы не смущало никого.