Корутины в Kotlin (гайд)

в 9:05, , рубрики: java, kotlin, Блог компании «Альфа-Банк», многопоточность, Программирование, разработка

Корутины в Kotlin (гайд) - 1

Simon Wirtz в своем блоге публикует достаточно много интересных постов о Kotlin.
Представляю вашему вниманию перевод одного из них.

Введение и мотивация

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

Корутины — это, несомненно, одна из “больших фич”, как сказано в блоге JetBrains:

Все мы знаем, что плохо блокировать под нагрузкой, что чуть ли не везде в пример ставится GO, и что мир становится все более асинхронным и основанным на обработке уведомлений. Многие языки (начиная с C# в 2012) поддерживают асинхронное программирование из коробки при помощи таких конструкций как async/await. В Kotlin мы исходили из того, что такие конструкции могут быть объявлены в библиотеке, и async не будет ключевым словом, а будет простой функцией. Такой дизайн позволяет реализовать различный асинхронный API: future/promises, callback-passing, и т.д. Также есть все необходимое для реализации ленивых генераторов (yield) и некоторых других функциональностей.

Другими словами, корутины были представлены для простой реализации многопоточного программирования. Наверняка многие из вас работали с Java, ее Thread-классом и классами для многопоточного программирования. Я и сам много с ними работал и убежден в зрелости их решений.

Многопоточность Java vs Kotlin корутины

Если вы все еще испытываете сложности с потоками и многопоточностью в Java, то я вам рекомендую книгу Java Concurrency in Practice.

Конечно, реализация из Java, с инженерной точки зрения, хорошо спроектирована, но ее сложно использовать в повседневной работе и она достаточно многословна. Помимо этого, в Java не так много реализаций для не блокирующего программирования. Часто можно себя поймать на том, что, запуская поток, ты совсем забываешь, что быстро попадаешь в блокирующий код (на блокировках, ожиданиях и т.д.) Альтернативный не блокирующий подход тяжело применять в повседневной работе, и в нем легко ошибиться.

Корутины, с другой стороны, выглядят как простой последовательный код, пряча всю сложность внутри библиотек. В то же время они предоставляют возможность запускать асинхронный код без всяких блокировок, что открывает большие возможности для различных приложений. Вместо блокировки потоков вычисления становятся прерываемыми. JetBrains описывают корутины как “легковесные потоки”, конечно, не те Threads, что мы знаем в Java. корутины очень дешевы в создании, и накладные расходы в сравнении с потоками не идут ни в какое сравнение. Как вы дальше увидите, корутины запускаются в Threads под управлением библиотеки. Другое весомое отличие — ограничения. Количество потоков ограничено, так как они на самом деле соответствуют нативным потокам. Создание корутины, с другой стороны, практически бесплатно, и даже тысячи их могут быть легко запущены.

Стили многопоточного программирования

В различных языках можно встретить самые разные подходы к многопоточному программированию: основанные на callback (JavaScript), future/promise (Java, JavaScript), async/await подход (С#) и т.д. Все они могут быть реализованы при помощи корутин благодаря тому, что они не навязывают стиль программирования. Напротив, любой стиль либо уже реализован, либо может быть реализован с их помощью.

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

Концепция корутин

Нельзя сказать, что понятие “корутины” новое. Согласно статье из Wikipedia, само название уже было известно в 1958 году. Многие современные языки программирования предоставляют нативную поддержку: C#, Go, Python, Ruby, и т.д. Реализация корутин, и в Kotlin в том числе, часто основана на так называемых “Continuations”, которые являются абстрактным представлением управляемого состояния в компьютерных программах. Мы еще вернемся к тому, как они работают (реализация корутин).

Начало. Основы

Есть исчерпывающий материал, доступный на сайте kotlinlang.org, в котором хорошо описано, как настроить проект для работы с корутинами. Посмотрите подробней на материал по предыдущей ссылке или просто возьмите за основу код из моего репозитория на GitHub.

Ингредиенты корутин

Как уже было сказано, библиотека с корутинами предоставляет понятный высокоуровневый API, который позволяет нам быстро начать работу. Единственный модификатор, который нужно выучить, это suspend. Он используется в качестве дополнительного модификатора у методов, чтобы пометить их как прерываемые.

Чуть позже посмотрим на несколько простых примеров из API, а пока, для начала, давайте подробнее рассмотрим, что из себя представляет функция suspend.

Прерываемые функции

Корутины основаны на ключевом слове suspend, которое используется для того, чтобы показать, что функция может быть прервана. Другими словами, вызов такой функции может быть прерван в любой момент. Такие функции могут быть вызваны только из корутин, которым, в свою очередь, требуется по крайней мере одна запущенная функция.

suspend fun myMethod(p: String): Boolean {
    //...
}

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

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

Перейдем к примерам

После всех бла-бла-бла перейдем к конкретным примерам. Начнем с основ:

fun main(args: Array<String>) = runBlocking { //(1)
    val job = launch(CommonPool) { //(2)
        val result = suspendingFunction() //(3)
        println("$result")
    }
    println("The result: ")
    job.join() //(4)
}
>> prints "The result: 5"

В этом примере две функции, (1) runBlocking и (2) launch, являются примерами использования билдеров корутин. Существует большое количество различных билдоров, каждый из которых создает корутину для различных целей: launch (создать и забыть), async (вернуть promise), runBlocking (заблокировать поток) и так далее.

Внутренняя корутина, созданная при помощи (2) launch, выполняет всю работу. Вызов прерываемой функции (3) может быть прерван в любое время, результат будет выведен после вычисления. В основном потоке после старта корутин будет выведено значение String до того, как корутина завершится. Корутина, запущенная через launch, возвращает сразу Job, который может быть использован для отмены выполнения или для ожидания вычислений при помощи (4) join(). А так как вызов join() может быть прерываемым, нам необходимо обернуть его в другую корутину, для чего часто используется runBlocking. Этот билдер (1) был специально создан, чтобы предоставить возможность для кода, написанного в стиле прерываний, быть вызванным в обычном блокирущем виде” (Заметка из API). Если же убрать join (4), то программа завершится прежде, чем корутина выведет значение результата.

Заглянем глубже в кроличью нору

Рассмотрим пример, более приближенный к реальности. Представьте, что в приложении вам нужно отправить email. Запрос получателей и генерация текста сообщения занимают значительное время. Оба процесса независимы, и соответственно мы можем их выполнять параллельно.

suspend fun sendEmail(r: String, msg: String): Boolean { //(6)
    delay(2000)
    println("Sent '$msg' to $r")
    return true
}

suspend fun getReceiverAddressFromDatabase(): String { //(4)
    delay(1000)
    return "coroutine@kotlin.org"
}

suspend fun sendEmailSuspending(): Boolean {
    val msg = async(CommonPool) {             //(3)
        delay(500)
        "The message content"
    }
    val recipient = async(CommonPool) {  //(5)
        getReceiverAddressFromDatabase()  
    } 
    println("Waiting for email data")
    val sendStatus = async(CommonPool) {
        sendEmail(recipient.await(), msg.await()) //(7)
    }
    return sendStatus.await() //(8)
}

fun main(args: Array<String>) = runBlocking(CommonPool) { //(1)
    val job = launch(CommonPool) {
        sendEmailSuspending() //(2)
        println("Email sent successfully.")   
    }
    job.join() //(9)
    println("Finished")
}

Можно немного упростить код автора

Код для sendEmailSuspending и main

suspend fun sendEmailSuspending(): Boolean {
    val msg = async(CommonPool) {
        delay(500)
        "The message content"
    }
    val recipient = async(CommonPool) { getReceiverAddressFromDatabase() }
    println("Waiting for email data")

    return sendEmail(recipient.await(), msg.await())
}

fun main(args: Array<String>) = runBlocking(CommonPool) {
    sendEmailSuspending()
    println("Email sent successfully.")
    println("Finished")
}

Сначала, как мы уже видели в предыдущем примере, мы используем (1) launch билдер внутри runBlocking, поэтому мы можем (9) подождать выполнения корутины. В (2) мы вызываем прерываемую функцию sendEmailSuspending. Внутри этого метода мы используем две параллельные задачи: (3) для получения текста сообщения и (4) для вызова getReceiverAddressFromDatabase, чтобы получить адрес. Обе задачи выполняются в параллельных корутинах при помощи async. Стоит также отметить, что вызов delay не является блокирующим, он используется для прерывания выполнения корутины.

async билдер

Данный билдер действительно простой по своей концепции. В других языках он вернул бы promise, который, строго говоря, представлен в Kotlin в виде типа Deferred. Все эти сущности как promise, future, deferred или delay обычно взаимозаменяемы и являются описанием одного и того же. Асинхронный объект, который “обещает” вернуть результат вычислений и который можно подождать в любое время.

Мы уже видели “ожидающую” часть Deferred объектов из Kotlin в (7), где прерываемая функция вызывалась с результатами ожидания обоих методов. Метод await() вызывается на экземпляре объекта Deferred, вызов которого прерывается до того, пока не будет доступен результат. Вызов sendEmail также обернут в асинхронный билдер, чтобы мы могли подождать выполнения.

Что пропустили: CoroutineContext

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

Текущий контекст может быть использован для запуска внутренних корутин, и как результат, дочерний Job будет является потомком внешнего. Это, в свою очередь, позволяет отменять всю иерархию корутин при помощи одного вызова на родительском Job.
CoroutineContext содержит различные типы Element, одним из которых является CoroutineDispatсher.

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

launch(CommonPool + CoroutineName("mycoroutine")){...}   

Общее изменяемое состояние

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

Что ж, это как раз тот вопрос, которым я недавно задавался, и это резонный вопрос для большинства корутин, использующих пул потоков. Для синхронизации мы можем использовать различные техники: потокобезопасные структуры данных, ограничение на выполнение в одном потоке или использовать блокировки (посмотрите подробнее Mutex)
Помимо общих паттернов, корутины из Kotlin поощряют нас использовать стиль “обмен через коммуникацию” (смотрите QA).

В частности, для коммуникации хорошо подходит “актор”. Его можно использовать в корутинах, которые могут отправлять/принимать сообщения от него. Давайте посмотрим на примере:

Акторы

sealed class CounterMsg {
    object IncCounter : CounterMsg() // one-way message to increment counter
    class GetCounter(val response: SendChannel<Int>) : CounterMsg() // a request with channel for reply.
}

fun counterActor() = actor<CounterMsg>(CommonPool) { //(1)
    var counter = 0 //(9) actor state, not shared
    for (msg in channel) { // handle incoming messages
        when (msg) {
            is CounterMsg.IncCounter -> counter++ //(4)
            is CounterMsg.GetCounter -> msg.response.send(counter) //(3)
        }
    }
}

suspend fun getCurrentCount(counter: ActorJob<CounterMsg>): Int { //(8)
    val response = Channel<Int>() //(2)
    counter.send(CounterMsg.GetCounter(response))
    val receive = response.receive()
    println("Counter = $receive")
    return receive
}

fun main(args: Array<String>) = runBlocking<Unit> {
    val counter = counterActor()

    launch(CommonPool) { //(5)
            while(getCurrentCount(counter) < 100){
                delay(100)
                println("sending IncCounter message")
                counter.send(CounterMsg.IncCounter) //(7)
            }
        }

    launch(CommonPool) { //(6)
        while ( getCurrentCount(counter) < 100) {
            delay(200)
        }
    }.join()
    counter.close() // shutdown the actor
}

На примере выше мы использовали Actor, который является корутиной сам по себе и может быть использован в любом контексте. Актор содержит текущее состояние приложения, которое содержится в counter. Тут мы также встречаем еще одну интересную функциональность (2) Channel

Каналы (Channel)

Channels предоставляют нам возможность обмена потоком значений, что очень похоже на то, как мы используем BlockingQueue (реализуя паттерн producer-consumer) в Java, только без всяких блокировок. Кроме того, send и receive являются прерываемыми функциями и используются для получения и отправки сообщений через канал, реализующий FIFO стратегию.

Актор по умолчанию содержит в себе такой канал и может быть использован в других корутинах для передачи сообщений в него. В примере выше актор перебирает сообщения из своего собственного канала (for работает тут с прерываемыми вызовами), обрабатывая их в соответствии с их типом: сообщение (4) IncCounter увеличивает значение counter в акторе, тогда как сообщение (3) GetCounter заставляет актор вернуть значение counter в виде передачи независимого значения в канал SendChannel из GetCounter.

Первая корутина в методе (5) main просто для удобства, она посылает сообщение (7) IncCounter в актор до тех пор, пока значение counter будет меньше 100. Вторая (6) ожидает, пока значение счетчика будет меньше 100. Обе корутины используют прерываемую функцию (8) getCurrentCounter, в которую посылают сообщения GetCounter и прерываются на ожидании receive в ответе.

Как мы видим, все состояние изолировано в конкретном акторе. Это решает проблему общего изменяемого состояния.

Другие функциональности и примеры

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

Как они работают — реализация корутин

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

Корутины не основаны ни на функциональности JVM, ни на функциональности операционной системы. Вместо этого корутины и прерываемые функции преобразуются компилятором в конечный автомат состояний, который может перехватывать прерывания и отправлять их в прерываемые функции, сохраняя при этом состояние. Все это возможно благодаря Contineation, который добавляется компилятором, в виде дополнительного неявного параметра, в каждый вызов прерываемой функции. Это так называемый стиль Continuation-passing. Более подробное описание можно найти тут.

Советы от Романа Элизарова

Не так давно мне удалось поговорить с Романом Элизаровым из JetBrains, благодаря которому во многом и появились корутины в Kotlin. Позвольте мне поделиться полученной информацией с вами:

В: Первый вопрос, который у меня возникает: когда нужно использовать корутины и есть ли такие ситуации, где все еще необходимо будет использовать потоки?

О: Корутины нужны для асинхронных задач, которые ожидают чего-либо большую часть времени. Потоки для интенсивных CPU задач.

В: Я упоминал, что фраза “легковесные потоки” звучит немного обманчиво для меня, в особенности, если учитывать то, что корутины основаны на потоках и выполняются в пуле потоков. Мне кажется, корутины больше похожи на “таск”, который выполняется, прерывается, останавливается.

О: Фраза “легковесные потоки” скорее поверхностна, корутины во многом ведут себя как потоки с точки зрения пользователей

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

О: Можно использовать известные паттерны для синхронизации, но все же предпочтительней вообще не иметь общего состояния при использовании корутин. Вместо этого корутины “поощряют стиль обмена через коммуникацию”.

Выводы

Корутины — очень мощный функционал, который появился в Kotlin. Пока я не познакомился с корутинами, мне казалось, что многопоточности из Java вполне достаточно.

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

Корутины также позволяют нам использовать различные подходы для написания конкурентного кода, каждый из которых либо уже реализован в библиотеке (kotlinx.coroutine), либо может быть легко воплощен с ее помощью.

Java-разработчикам, скорее всего, привычнее отправлять таски пулу потоков и дожидаться результата future при помощи ExecutorService, что, как мы видели, легко реализуется при помощи async/await. Да, это не полностью эквивалентная замена, но все же это значительное улучшение.

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

Перспективы

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

Отлично! Вы дочитали до конца весь пост. Надеюсь, что вы нашли что-нибудь полезное для себя. Буду рад любому отзыву.

Simon

Автор: Евгений Захаров

Источник

Поделиться