Асинхронность NIO и Kotlin — а есть ли связь?

от автора

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

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‑операции, а просто её инициировал и продолжал работу, время от времени проверяя, не готовы ли данные.

Асинхронная работа с I/O операциями (идея)

Асинхронная работа с 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.

Типовая схема взаимодействия через Async I/O

Типовая схема взаимодействия через Async I/O

Отдельно нужно напомнить про имеющиеся ограничения в поддержке работы с файловой системой через асинхронное 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)    }}

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

Давайте ещё посмотрим примеры:

// Пример функции на coroutinesfun 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)        })    })

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

То есть, вместо решения бизнес‑задачи мы начинаем тратить большую часть нашего времени на решение технических вопросов.

Ну а теперь посмотрим на код с корутинами:

// Пример функции на coroutinessuspend 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‑библиотеки выносим в отдельный пул

и сосредотачиваемся на бизнес‑логике!!

Ссылки и благодарности

ссылка на оригинал статьи https://habr.com/ru/articles/1053868/