Генераторы, Асинхронность, Корутины

В статье я разбираю основные концепции необходимые для понимания корутин и использование корутин в асинхронном программировании. Я рассматриваю генераторы, кратко объясняю асинхронные неблокирующие 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 операции.

Работает это так:

  1. Корутина corouts запускает первую корутину doRequest1 на выполнение и засыпает.
  2. Когда doRequest1 выполнится, корутина corouts проснется, напечатает complete 1, запустит вторую корутину doRequest2 на выполнение и заснет.
  3. и т.д. пока corouts не выполнится до конца.

Как видно из примера, появилось много синтаксического сахара, который позволяет скрывать от нас запуск корутины через __next__(), отправку ей входных данных через send(), делая код простым и наглядным. В Kotlin синтаксического сахара еще больше.

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

Если вам интересны более глубокие технические подробности устройства корутин, советую вам прочитать Kotlin Coroutines Design proposal от JetBrains.