Введение в Kotlin Coroutines

Введение в Kotlin Coroutines

1. обзор

В этой статье мы рассмотрим сопрограммы на языке Kotlin. Проще говоря,coroutines allow us to create asynchronous programs in a very fluent way, и они основаны на концепции программированияContinuation-passing style.

Язык Kotlin предоставляет нам базовые конструкции, но может получить доступ к более полезным сопрограммам с библиотекойkotlinx-coroutines-core. Мы рассмотрим эту библиотеку, как только поймем основные строительные блоки языка Kotlin.

2. Создание сопрограммы сBuildSequence

Давайте создадим первую сопрограмму, используя функциюbuildSequence.

И давайте реализуем генератор последовательности Фибоначчи, используя эту функцию:

val fibonacciSeq = buildSequence {
    var a = 0
    var b = 1

    yield(1)

    while (true) {
        yield(a + b)

        val tmp = a + b
        a = b
        b = tmp
    }
}

Сигнатура функцииyield:

public abstract suspend fun yield(value: T)

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

Suspending functions can be created as standard Kotlin functions, but we need to be aware that we can only call them from within a coroutine. В противном случае мы получим ошибку компилятора.

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

В сопрограммеfibonacciSeq есть две точки приостановки. Во-первых, когда мы вызываемyield(1), а во-вторых, когда мы вызываемyield(a+b).

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

Мы можем проверить наш код, взяв некоторые элементы из последовательности Фибоначчи:

val res = fibonacciSeq
  .take(5)
  .toList()

assertEquals(res, listOf(1, 1, 2, 3, 5))

3. Добавление зависимости Maven дляkotlinx-coroutines

Давайте посмотрим на библиотекуkotlinx-coroutines, в которой есть полезные конструкции, построенные поверх основных сопрограмм.

Давайте добавим зависимость в библиотекуkotlinx-coroutines-core. Обратите внимание, что нам также необходимо добавить репозиторийjcenter:


    org.jetbrains.kotlinx
    kotlinx-coroutines-core
    0.16



    
        central
        http://jcenter.bintray.com
     

4. Асинхронное программирование с использованием программыlaunch() Coroutine

Библиотекаkotlinx-coroutines добавляет множество полезных конструкций, которые позволяют нам создавать асинхронные программы. Допустим, у нас есть дорогостоящая вычислительная функция, которая добавляетString во входной список:

suspend fun expensiveComputation(res: MutableList) {
    delay(1000L)
    res.add("word!")
}

Мы можем использовать сопрограммуlaunch, которая будет выполнять эту функцию приостановки неблокирующим образом - нам нужно передать пул потоков в качестве аргумента.

Функцияlaunch возвращает экземплярJob, для которого мы можем вызвать методjoin(), чтобы дождаться результатов:

@Test
fun givenAsyncCoroutine_whenStartIt_thenShouldExecuteItInTheAsyncWay() {
    // given
    val res = mutableListOf()

    // when
    runBlocking {
        val promise = launch(CommonPool) {
          expensiveComputation(res)
        }
        res.add("Hello,")
        promise.join()
    }

    // then
    assertEquals(res, listOf("Hello,", "word!"))
}

Чтобы иметь возможность протестировать наш код, мы передаем всю логику сопрограммеrunBlocking - это вызов блокировки. Следовательно, нашassertEquals() может выполняться синхронно после кода внутри методаrunBlocking().

Обратите внимание, что в этом примере, хотя методlaunch() запускается первым, это вычисление с задержкой. Основной поток продолжит добавление“Hello,” String в список результатов.

После задержки в одну секунду, которая вводится в функцииexpensiveComputation(), к результату будет добавлен“word!” String.

5. Сопрограммы очень легкие

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

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

@Test
fun givenHugeAmountOfCoroutines_whenStartIt_thenShouldExecuteItWithoutOutOfMemory() {
    runBlocking {
        // given
        val counter = AtomicInteger(0)
        val numberOfCoroutines = 100_000

        // when
        val jobs = List(numberOfCoroutines) {
            launch(CommonPool) {
                delay(1000L)
                counter.incrementAndGet()
            }
        }
        jobs.forEach { it.join() }

        // then
        assertEquals(counter.get(), numberOfCoroutines)
    }
}

Обратите внимание, что мы выполняем 100 000 сопрограмм, и каждый запуск добавляет значительную задержку. Тем не менее, нет необходимости создавать слишком много потоков, потому что эти операции выполняются асинхронно с использованием потока изCommonPool.

6. Отмена и тайм-ауты

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

Когда мы начинаем наше асинхронное действие с сопрограммойlaunch(), мы можем проверить флагisActive. Этот флаг устанавливается в значение false всякий раз, когда основной поток вызывает методcancel() в экземпляреJob:

@Test
fun givenCancellableJob_whenRequestForCancel_thenShouldQuit() {
    runBlocking {
        // given
        val job = launch(CommonPool) {
            while (isActive) {
                println("is working")
            }
        }

        delay(1300L)

        // when
        job.cancel()

        // then cancel successfully

    }
}

Это очень элегантно иeasy way to use the cancellation mechanism. В асинхронном действии нам нужно только проверить, равен ли флагisActivefalse, и отменить нашу обработку.

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

Например, мы можем повторить действие:

@Test(expected = CancellationException::class)
fun givenAsyncAction_whenDeclareTimeout_thenShouldFinishWhenTimedOut() {
    runBlocking {
        withTimeout(1300L) {
            repeat(1000) { i ->
                println("Some expensive computation $i ...")
                delay(500L)
            }
        }
    }
}

Если мы не определим тайм-аут, возможно, что наш поток будет заблокирован навсегда, потому что это вычисление зависнет. Мы не можем обработать этот случай в нашем коде, если время ожидания не определено.

7. Одновременное выполнение асинхронных действий

Допустим, нам нужно запустить два асинхронных действия одновременно, а потом дождаться их результатов. Если наша обработка занимает одну секунду, и нам нужно выполнить эту обработку дважды, время выполнения синхронного блокирования составит две секунды.

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

We can leverage the async() coroutine to achieve this, запустив обработку в двух отдельных потоках одновременно:

@Test
fun givenHaveTwoExpensiveAction_whenExecuteThemAsync_thenTheyShouldRunConcurrently() {
    runBlocking {
        val delay = 1000L
        val time = measureTimeMillis {
            // given
            val one = async(CommonPool) {
                someExpensiveComputation(delay)
            }
            val two = async(CommonPool) {
                someExpensiveComputation(delay)
            }

            // when
            runBlocking {
                one.await()
                two.await()
            }
        }

        // then
        assertTrue(time < delay * 2)
    }
}

После выполнения двух дорогостоящих вычислений мы приостанавливаем выполнение сопрограммы, выполняя вызовrunBlocking(). Как только результатыone иtwo будут доступны, сопрограмма возобновит работу, и результаты будут возвращены. Выполнение двух задач таким способом должно занять около одной секунды.

Мы можем передатьCoroutineStart.LAZY в качестве второго аргумента методуasync(), но это будет означать, что асинхронное вычисление не будет запущено до тех пор, пока не будет запрошено. Поскольку мы запрашиваем вычисления в сопрограммеrunBlocking, это означает, что вызовtwo.await() будет выполнен только после завершенияone.await():

@Test
fun givenTwoExpensiveAction_whenExecuteThemLazy_thenTheyShouldNotConcurrently() {
    runBlocking {
        val delay = 1000L
        val time = measureTimeMillis {
            // given
            val one
              = async(CommonPool, CoroutineStart.LAZY) {
                someExpensiveComputation(delay)
              }
            val two
              = async(CommonPool, CoroutineStart.LAZY) {
                someExpensiveComputation(delay)
            }

            // when
            runBlocking {
                one.await()
                two.await()
            }
        }

        // then
        assertTrue(time > delay * 2)
    }
}

The laziness of the execution in this particular example causes our code to run synchronously. Это происходит потому, что когда мы вызываемawait(), основной поток блокируется, и только после завершения задачиone запускается задачаtwo.

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

8. Заключение

В этой статье мы рассмотрели основы соплинов Kotlin.

Мы видели, чтоbuildSequence - это основной строительный блок каждой сопрограммы. Мы описали, как выглядит поток выполнения в этом стиле программирования с прохождением продолжения.

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

Реализация всех этих примеров и фрагментов кода можно найти вGitHub project - это проект Maven, поэтому его должно быть легко импортировать и запускать как есть.