Files
obsidian/Kotlin/Корутины/Билдеры корутин.md
T

20 KiB
Raw Blame History

title, description, tags
title description tags
Билдеры корутин launch, async, runBlocking, withContext, produce и actor - что выбрать и чем они различаются
kotlin
coroutines

Билдер - функция, которая создаёт корутину. Их немного, и выбор между ними определяется двумя вопросами: нужен ли результат и нужна ли новая корутина вообще.

Билдер Возвращает Блокирует поток Нужен результат
launch Job нет нет
async Deferred<T> нет да
runBlocking T да да
withContext T нет да, смена контекста
produce ReceiveChannel<T> нет поток значений
actor SendChannel<T> нет обработка сообщений

launch: запустить и не ждать результата

import kotlinx.coroutines.*

fun main() = runBlocking {
    val job = launch {
        delay(50)
        println("работа выполнена")
    }
    println("launch вернулся сразу: ${job.isActive}")
    job.join()
    println("дождались")
    // launch вернулся сразу: true
    // работа выполнена
    // дождались
}

launch возвращает Job - дескриптор для join(), cancel() и проверки состояния. Результат тела отбрасывается: тип всегда Job, даже если блок что-то вычислил.

import kotlinx.coroutines.*

fun main() = runBlocking {
    val job = launch { 42 }          // значение 42 теряется
    job.join()
    println(job::class.simpleName != null)      // true
}

async: запустить и получить результат

import kotlinx.coroutines.*
import kotlin.system.measureTimeMillis

suspend fun loadPrice(id: Int): Int { delay(100); return id * 10 }

fun main() = runBlocking {
    val time = measureTimeMillis {
        val a = async { loadPrice(1) }
        val b = async { loadPrice(2) }
        println("сумма: ${a.await() + b.await()}")      // сумма: 30
    }
    println("параллельно: ${time < 180}")                // параллельно: true
}

Deferred<T> - это Job с результатом: у него есть и join(), и await(). Вызов await() приостанавливает корутину до готовности значения.

await можно вызывать повторно - результат кешируется:

import kotlinx.coroutines.*

fun main() = runBlocking {
    var calls = 0
    val d = async { calls++; delay(20); "значение" }
    println(d.await())        // значение
    println(d.await())        // значение
    println("тело выполнилось раз: $calls")      // тело выполнилось раз: 1
}

Ленивый запуск - тело не начнётся до await() или start():

import kotlinx.coroutines.*

fun main() = runBlocking {
    val lazy = async(start = CoroutineStart.LAZY) {
        println("тело стартовало")
        "результат"
    }
    println("создан, но не запущен")
    delay(20)
    println(lazy.await())
    // создан, но не запущен
    // тело стартовало
    // результат
}

Внимание: ленивый async, у которого так и не вызвали await(), никогда не выполнится и заблокирует завершение области до её отмены.

runBlocking: мост из синхронного кода

import kotlinx.coroutines.*

suspend fun compute(): Int { delay(20); return 42 }

fun main() {
    val result = runBlocking { compute() }       // обычная функция вызывает suspend
    println(result)        // 42
}

runBlocking блокирует текущий поток до завершения корутины и всех её детей. Это единственный билдер, который не требует scope - он его создаёт.

Применимость:

Место Уместен ли runBlocking
функция main да
тесты (если нет runTest) да
CLI-утилита да
обработчик HTTP-запроса нет, фреймворк даёт scope
Android UI-поток категорически нет
внутри другой корутины нет, это блокировка потока

withContext: сменить контекст без новой корутины

import kotlinx.coroutines.*

suspend fun readFile(): String = withContext(Dispatchers.IO) {
    Thread.sleep(20)        // здесь блокирующий вызов допустим
    "содержимое"
}

fun main() = runBlocking {
    println(readFile())        // содержимое
}

withContext не создаёт новую корутину - он переключает контекст текущей и возвращает значение блока. Это главный инструмент для «main-safe» функций.

Разница с async { }.await() существенна:

import kotlinx.coroutines.*

suspend fun viaWithContext(): Int = withContext(Dispatchers.Default) { 1 }

suspend fun viaAsync(): Int = coroutineScope {
    async(Dispatchers.Default) { 1 }.await()      // лишняя корутина
}

fun main() = runBlocking {
    println("${viaWithContext()} ${viaAsync()}")      // 1 1
}

Результат одинаков, но async().await() создаёт объект Deferred и корутину зря. Правило: если результат нужен сразу, это withContext; async оправдан только когда между запуском и await есть другая работа.

Параллельная декомпозиция

import kotlinx.coroutines.*

data class Dashboard(val user: String, val orders: List<String>, val balance: Int)

suspend fun fetchUser(): String { delay(30); return "Иван" }
suspend fun fetchOrders(): List<String> { delay(40); return listOf("заказ 1") }
suspend fun fetchBalance(): Int { delay(20); return 1000 }

suspend fun loadDashboard(): Dashboard = coroutineScope {
    val user = async { fetchUser() }
    val orders = async { fetchOrders() }
    val balance = async { fetchBalance() }
    Dashboard(user.await(), orders.await(), balance.await())
}

fun main() = runBlocking {
    println(loadDashboard())
    // Dashboard(user=Иван, orders=[заказ 1], balance=1000)
}

Это каноничный шаблон: coroutineScope + несколько async + сборка результата. Время работы - максимум из трёх, а не сумма; при ошибке любого запроса остальные отменяются.

CoroutineStart

import kotlinx.coroutines.*

fun main() = runBlocking {
    launch(start = CoroutineStart.DEFAULT) { println("3: обычный, после текущего кода") }
    launch(start = CoroutineStart.UNDISPATCHED) { println("1: сразу, до первой приостановки") }
    val lazy = launch(start = CoroutineStart.LAZY) { println("4: только после start()") }
    println("2: тело main")
    lazy.start()
    delay(20)
    // 1: сразу, до первой приостановки
    // 2: тело main
    // 3: обычный, после текущего кода
    // 4: только после start()
}
Значение Поведение
DEFAULT запланировать запуск согласно диспетчеру
LAZY не запускать до start(), join() или await()
ATOMIC запустить, игнорируя отмену до первой точки приостановки
UNDISPATCHED выполнять в текущем потоке до первой приостановки

UNDISPATCHED используют, когда важно выполнить начало тела немедленно (например, зарегистрировать подписку до возврата из функции). Остальные значения нужны редко.

produce и actor

produce - билдер, возвращающий канал с результатами:

import kotlinx.coroutines.*
import kotlinx.coroutines.channels.*

fun CoroutineScope.numbers(): ReceiveChannel<Int> = produce {
    for (i in 1..3) {
        delay(10)
        send(i)
    }
}

fun main() = runBlocking {
    val channel = numbers()
    for (value in channel) print("$value ")
    println()        // 1 2 3
}

actor - обратное: корутина, принимающая сообщения:

import kotlinx.coroutines.*
import kotlinx.coroutines.channels.*

sealed interface Msg {
    data class Add(val value: Int) : Msg
    data class GetSum(val response: CompletableDeferred<Int>) : Msg
}

fun CoroutineScope.counter() = actor<Msg> {
    var sum = 0
    for (msg in channel) {
        when (msg) {
            is Msg.Add -> sum += msg.value
            is Msg.GetSum -> msg.response.complete(sum)
        }
    }
}

fun main() = runBlocking {
    val c = counter()
    c.send(Msg.Add(10))
    c.send(Msg.Add(5))
    val response = CompletableDeferred<Int>()
    c.send(Msg.GetSum(response))
    println(response.await())        // 15
    c.close()
}

Оба помечены @ObsoleteCoroutinesApi - их планируют заменить. Для потоков значений используйте Flow, для разделяемого состояния - Mutex или StateFlow.

Как выбирать

graph TD
    A["Нужна корутина"] --> B{"Вы в suspend-функции<br/>или в scope?"}
    B -->|"нет, обычный код"| C["runBlocking"]
    B -->|"да"| D{"Нужен результат?"}
    D -->|"нет, fire-and-forget"| E["launch"]
    D -->|"да"| F{"Нужен прямо сейчас?"}
    F -->|"да, просто другой поток"| G["withContext"]
    F -->|"нет, сначала другая работа"| H["async + await"]

[!info] Отличия от Java

Java Kotlin
executor.execute(task) launch
executor.submit(task) -> Future<T> async -> Deferred<T>
future.get() (блокирует) deferred.await() (приостанавливает)
CompletableFuture.supplyAsync async
future.get() в main runBlocking
CompletableFuture.allOf awaitAll
явная передача пула в каждый вызов контекст наследуется

await() приостанавливает, get() блокирует. Это ключевая разница. Future.get() занимает поток до готовности результата, поэтому в пуле из 8 потоков 8 ожидающих задач полностью его парализуют - классический deadlock при вложенных get(). Deferred.await() освобождает поток, поэтому миллион ожидающих корутин живут в нескольких потоках.

Контекст наследуется, а не передаётся. В Java пул приходится протаскивать в каждый вызов: supplyAsync(task, executor), и забытый второй аргумент молча отправит задачу в ForkJoinPool.commonPool. В Kotlin дочерняя корутина наследует контекст родителя:

import kotlinx.coroutines.*

fun main() = runBlocking {
    withContext(Dispatchers.IO) {
        launch {
            val name = Thread.currentThread().name
            val onIo = name.contains("DefaultDispatcher")
            println("ребёнок унаследовал IO: $onIo")
        }
    }
    // ребёнок унаследовал IO: true
}

CompletableFuture комбинируется методами, корутины - кодом. Java предлагает thenCombine, allOf, anyOf, thenCompose - по методу на каждый случай. В Kotlin сбор результатов пишется обычным кодом (listOf(a.await(), b.await())), поэтому if, циклы и try/catch работают там естественно.

Нет аналога runBlocking в Java, потому что он не нужен: future.get() и так блокирует. Обратная ситуация - в Kotlin runBlocking нужен именно как явная граница между блокирующим и приостанавливаемым миром, и эта явность полезна: видно, где поток встаёт.

launch против submit в обработке ошибок. Исключение в submit попадает в Future и теряется, если не вызвать get(). Исключение в launch уходит родителю и обязательно будет обработано - либо родителем, либо CoroutineExceptionHandler, либо уронит приложение. Терять ошибки молча корутины не позволяют. Подробнее: Исключения в корутинах

Сравнить: Взаимодействие между потоками в Java

[!warning] Подводные камни runBlocking внутри корутины блокирует поток диспетчера:

import kotlinx.coroutines.*

fun main() = runBlocking {
    withContext(Dispatchers.Default.limitedParallelism(1)) {
        launch {
            runBlocking { delay(50) }        // держит единственный поток
            println("первая закончила")
        }
        launch { println("вторая ждала освобождения потока") }
    }
    // первая закончила
    // вторая ждала освобождения потока
}

На ограниченном диспетчере это прямой путь к deadlock. Внутри корутины нужен withContext, а не runBlocking.

async без await проглатывает исключение до конца области:

import kotlinx.coroutines.*

fun main() = runBlocking {
    val result = runCatching {
        coroutineScope {
            async { throw IllegalStateException("ошибка в async") }
            delay(20)
            "тело завершилось"
        }
    }
    println(result.exceptionOrNull()?.message)      // ошибка в async
}

Ошибка не исчезла - она отменила область при завершении. Но заметить её в месте возникновения невозможно, а если async висит в SupervisorJob, она потеряется совсем.

Ленивый async без await подвешивает область навсегда:

import kotlinx.coroutines.*

fun main() = runBlocking {
    val job = launch {
        async(start = CoroutineStart.LAZY) { "никогда не выполнится" }
        println("тело launch закончилось")
    }
    val finished = withTimeoutOrNull(200) { job.join(); true } ?: false
    println("launch завершился: $finished")
    job.cancel()
    // тело launch закончилось
    // launch завершился: false
}

Ленивый Deferred остаётся незавершённым ребёнком, а родитель обязан дождаться всех детей - поэтому launch не завершится никогда, даже после выхода из тела. Без job.cancel() программа повиснет. Это тихая утечка: ни ошибки, ни предупреждения.

async последовательно - это не параллельно:

import kotlinx.coroutines.*
import kotlin.system.measureTimeMillis

suspend fun slow(): Int { delay(100); return 1 }

fun main() = runBlocking {
    val wrong = measureTimeMillis {
        val a = async { slow() }.await()      // await сразу — ждём здесь же
        val b = async { slow() }.await()
        println(a + b)        // 2
    }
    println("последовательно: ${wrong >= 200}")      // последовательно: true
}

await() сразу после async убивает параллельность. Запускайте все async, потом ждите.

launch теряет результат молча. Тип Job не содержит значения, поэтому launch { computeSomething() } компилируется и ничего не делает с результатом. Компилятор не предупредит.

withContext на каждый мелкий вызов стоит дорого:

import kotlinx.coroutines.*

suspend fun badLoop(): Int = withContext(Dispatchers.Default) {
    var sum = 0
    repeat(1000) { sum += it }      // одно переключение — правильно
    sum
}

fun main() = runBlocking {
    println(badLoop())        // 499500
}

Переключение контекста - это передача задачи в другой пул с синхронизацией. Выносите withContext наружу цикла, а не внутрь.

runBlocking в Android UI-потоке вызывает ANR. Это самая частая серьёзная ошибка: UI замирает до конца корутины. В Android используйте lifecycleScope/viewModelScope.

produce и actor устарели. Они помечены @ObsoleteCoroutinesApi, их API может измениться. Для новых проектов - Flow и Channel напрямую, либо Mutex для состояния.

CoroutineStart.ATOMIC игнорирует отмену:

import kotlinx.coroutines.*

fun main() = runBlocking {
    @OptIn(ExperimentalCoroutinesApi::class)
    val job = launch(start = CoroutineStart.ATOMIC) {
        println("тело всё равно начнётся")
        delay(100)
        println("а это уже нет")
    }
    job.cancel()
    job.join()
    // тело всё равно начнётся
}

Начало тела выполнится даже после cancel(). Это нужно для гарантированного освобождения ресурсов, но легко превращается в сюрприз.

Возврат Deferred из публичной функции ломает структурированность - вызывающий может не вызвать await(), и ошибка потеряется. Возвращайте значение из suspend-функции.

Далее: Диспетчеры и контекст