В статье я разбираю основные концепции необходимые для понимания корутин и использование корутин в асинхронном программировании. Я рассматриваю генераторы, кратко объясняю асинхронные неблокирующие IO операции, рассказываю как концепция корутин вытекает из генераторов, а затем как корутины превращаются в примитив для асинхронного программирования.
Статья из моего telegram канала: Senior’s Blog. Подписывайтесь на канал ;-)
Генераторы
Генераторы (Generators
) достаточно интересная структура, которая мало известна в чистой Java.
Если вы пишите на Python или Kotlin, то вы скорее всего сталкивались с ними.
Генератор больше всего похож на ленивый итератор (lazy iterators
),
по которому можно проходить циклом получая из него элементы.
При каждом новом обращении к генератору он выдает новый элемент и затем “засыпает”.
Это засыпание и является основной фишкой генераторов.
Давайте рассмотрим пару примеров использования генераторов.
Первое - это создание бесконечных итераторов, которые не занимают большого количества памяти.
Пример на Python:
def infinite_sequence():
num = 0
while True:
yield num
num += 1
В Kotlin есть вспомогательные функции, которые позволяют написать тоже самое в одну строчку:
val infinite_sequence = generateSequence(0) { it + 1 }
Теперь можно вызвать этот генератор из цикла и бесконечно получать из него числа.
Python:
for i in infinite_sequence():
print(i)
Kotlin:
for (i in infinite_sequence) {
println(i)
}
Как видно из примера бесконечные генераторы не хранят всю сгенерированную последовательность.
Их состояние - это последний сгенерированный элемент на основе которого они генерируют следующий элемент.
В примере на Python
явно видно ключевое слово yield
на котором генератор засыпает и ждет следующего обращения к нему,
а в Kotlin
эта конструкция скрыта за абстракциями.
Второе применение - инкапсуляция запроса и получения данных из какого-то источника, например из БД.
def SQL_items():
cursor = connection.cursor()
cursor.execute("SELECT ...")
row = cursor.fetchone()
while row not None:
yield row
row = cursor.fetchone()
Этот генератор делает запрос к БД, получает элемент из курсора, отдает его пользователю и засыпает на операторе yield
до следующего вызова и так пока не закончатся элементы в курсоре.
Пользователь в любой момент времени может прекратить получение элементов и закрыть генератор.
Как видно из примера все элементы в памяти не хранятся, а считываются из БД по мере необходимости.
Генераторы могут быть полезны в нескольких юзкейсах, но больший интерес представляют корутины (Coroutines
), которые основаны на генераторах, о них я расскажу чуть ниже.
Асинхронные неблокирующие IO операции
Перед знакомством с корутинами нужно вспомнить что такое асинхронные неблокирующие IO операции.
Основы асинхронного программирования всем давно известны: основной поток не блокируется на длительное время, все долгие операции выполняются в фоне, а основной поток просто дожидается результатов этих долгих операций. Во время ожидания он может заниматься другими полезными операциями.
Обычно долгие операции - это операции ввода/вывода
(IO), такие как запросы к БД, запросы к другим сервисам, чтение/запись файлов.
Асинхронность таких операций достигается за счет подходов Callbacks
, Futures
, Actors
и т.д.
Асинхронные операции делятся на два типа: блокирующие (blocking
) и неблокирующие (non-blocking
) IO операции.
Асинхронность c блокирующим IO
использует тредпулы (ThreadPool
, Executor
) - это обычные блокирующие IO операции.
Например, запрос через http-клиент или считывание файла, это операции которые блокируют поток в тредпуле до завершения операции.
Асинхронность с неблокирующеми IO
использует специальное системное API
,
которое позволяет запустить IO операцию и получить уведомление о ее завершении, либо самостоятельно проверить завершилась ли операция.
Такой подход позволяет не использовать потоки и тредпулы, благодаря чему экономятся ресурсы сервера.
Стоит быть внимательным и осторожным когда вы используете клиенты возвращающие Futures
,
т.к. под этой Futures
может находится как блокирующая так и не блокирующая IO операция.
Есть еще бессмысленный и бесполезный подход: синхронные операции с блокирующим IO, т.е. запускается IO операция, затем в цикле постоянно проверяется готов ли ее результат, тем самым явно блокируя поток. Использовать такой подход разумно только в скриптах и прототипах, когда не жалко ресурсы и лень делать нормально.
В большинстве современных высоконагруженных системах применяются именно асинхронные неблокирующие IO операции. Они позволяют достичь гораздо большей производительности по сравнению с блокирующими IO операциями на том же железе.
Подробнее про устройство неблокирующих IO
операций на Linux можно прочитать в статье Asynchronous I/O and event notification on linux.
Про блокирующие и неблокирующие сокеты можно прочитать в статье Asynchronous programming. Blocking I/O and non-blocking I/O.
Концепция корутин
Понимая что такое генераторы и неблокирующие асинхронные операции можно познакомиться с корутинам (coroutines
).
Помните что такое генераторы? Это ленивые итераторы (lazy iterators
), которые умеют засыпать в промежутках между запросами.
За счет этой особенности они бесконечно генерируют и выдают элементы не занимая при этом много памяти.
Корутины (Сoroutines) - это концепция очень похожая на генератор. Корутины представляют собой функции, которые могут засыпать в определенных местах, приостанавливая свое выполнение, а затем просыпаться и продолжать выполнение.
Пример корутины на Python:
def print_name(prefix):
print("Searching prefix: {}".format(prefix))
while True:
name = (yield)
if prefix in name:
print(name)
# создание корутины
corou = print_name("Dear")
# Запуск корутины
# отработает первый print
# корутина заснет на yield операторе
corou.__next__()
# отправка входных данных корутине
corou.send("Dear Atul")
corou.send("Atul")
corou.send("Dear Pauls")
В Kotlin
простая корутина будет выглядеть схожим образом.
Корутину еще возможно представить как микро-микро сервис (почти актор) с которым можно общаться через отправку сообщений методом send()
и который после обработки полученного сообщения уснет.
Корутины выглядят интересно, но все еще непонятно зачем они нужны…
Выше я не зря писал про асинхронные неблокирующие операции, если подружить корутины с асинхронностью то получится очень интересная концепция.
Корутины в реальной жизни
Когда корутина во время своего выполнения встречает асинхронную операцию, то после запуска этой асинхронной операции корутина просто засыпает, а когда асинхронная операция завершается, то корутина просыпается и продолжает свое выполнение. Во время того как корутина засыпает, она полностью освобождает поток в котором выполнялась и в этом потоке могут выполняться другие корутины.
В результате появляется удобная абстракция для работы с асинхронными операциями.
Как я уже писал выше, для работы с асинхронными операциями используются такие подходы как Callbacks
, Futures
, Actors
и т.д.
Основное преимущество корутин в том, что код на них выглядит почти так же как и императивный синхронный код, который все мы привыкли читать и писать.
Рассмотрим пример на Python:
import asyncio
async def corouts():
await doRequest1()
print("complete 1")
await doRequest2()
print("complete 2")
await doRequest3()
print("complete 3")
asyncio.run(corouts())
В этом примере мы видим корутину corouts
, которая вызывает три другие корутины doRequestN
, которые, выполняют какие-то асинхронные неблокирующие IO операции.
Работает это так:
- Корутина
corouts
запускает первую корутинуdoRequest1
на выполнение и засыпает. - Когда
doRequest1
выполнится, корутинаcorouts
проснется, напечатаетcomplete 1
, запустит вторую корутинуdoRequest2
на выполнение и заснет. - и т.д. пока
corouts
не выполнится до конца.
Как видно из примера, появилось много синтаксического сахара, который позволяет скрывать от нас запуск корутины через __next__()
,
отправку ей входных данных через send()
, делая код простым и наглядным. В Kotlin
синтаксического сахара еще больше.
В примере вложенные корутины выполняются последовательно, но их можно запускать параллельно с помощью специальных функций.
В Kotlin
, если бы эти корутины содержали блокирующие запросы, то можно было бы указать в каком тредпуле выполнять эти корутины.
Корутины имеют множество уже готовых и удобных фишек о которых вы можете прочитать в документации конкретного языка.
Если вам интересны более глубокие технические подробности устройства корутин, советую вам прочитать Kotlin Coroutines Design proposal от JetBrains.