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


Статья про то что такое генераторы, какая бывает асинхронность и что такое корутины.

Статья из моего telegram канала: Senior’s Blog. Подписывайтесь на канал ;-)

Генераторы

Генераторы (Generators) достаточно интересная структура которая мало известна в чистой Java. Если вы пишите на Python или Kotlin, то вы скорее всего сталкивались с ними.

Generators представляет из себя что то вроде ленивого итератора (lazy iterators) по которому можно проходить циклом получая из него элементы. При каждом новом обращении к генератору он выдает новый элемент и после этого обращения “засыпает”. Это засыпание и является основной фишкой генераторов.

Давайте рассмотрим пару применений генераторов.

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

Пример на Python:

def infinite_sequence():
    num = 0
    while True:
        yield num
        num += 1

В котлине есть вспомогательные функции, которые позволяют написать тоже самое в одну строчку:

val infinite_sequence = generateSequence(0) { it + 1 }

Теперь можно вызвать этот генератор из цикла и бесконечно получать из него числа.

Python:

for i in infinite_sequence():
    print(i)

Kotlin:

for (i in infinite_sequence) {
    println(i)
}

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

Второе применение - инкапсуляция запроса и получения данных из какого то источника, например из БД.

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), такие как запросы к БД, запросы к другим сервисам, чтение/запись файлов. Асинхронность этих операций достигается за счет таких подходов как 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.