Files
obsidian/Kotlin/Корутины/Корутины.md
T

18 KiB
Raw Blame History

title, description, tags
title description tags
Корутины Что такое приостанавливаемое вычисление, зачем оно нужно и как устроен раздел
kotlin
coroutines

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

import kotlinx.coroutines.*

suspend fun loadUser(id: Int): String {
    delay(100)                      // приостановка, а не блокировка
    return "пользователь $id"
}

suspend fun loadOrders(userId: Int): List<String> {
    delay(100)
    return listOf("заказ 1", "заказ 2")
}

fun main() = runBlocking {
    val user = loadUser(1)
    val orders = loadOrders(1)
    println("$user: $orders")       // пользователь 1: [заказ 1, заказ 2]
}

Код читается как последовательный, но на delay поток освобождается и может выполнять другие корутины. Это и есть вся идея.

Почему не потоки

Поток ОС стоит около мегабайта стека и переключается через ядро. Корутина - это объект в куче, её «переключение» - обычный вызов метода.

import kotlinx.coroutines.*

fun main() = runBlocking {
    val jobs = List(50_000) {
        launch { delay(1000) }
    }
    println("запущено корутин: ${jobs.size}")       // запущено корутин: 50000
    jobs.forEach { it.join() }
    println("все завершились")                       // все завершились
}

Пятьдесят тысяч потоков повалили бы JVM с OutOfMemoryError; пятьдесят тысяч корутин спокойно живут в нескольких потоках. Подробнее: Корутины против потоков

Приостановка вместо блокировки

Разница принципиальная. Блокировка занимает поток, ничего не делая:

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

fun main() = runBlocking {
    val blocking = measureTimeMillis {
        withContext(Dispatchers.Default) {
            List(4) { launch { Thread.sleep(500) } }.forEach { it.join() }
        }
    }

    val suspending = measureTimeMillis {
        List(4) { launch { delay(500) } }.forEach { it.join() }
    }

    println("блокировка дольше: ${blocking > suspending}")      // блокировка дольше: true
    println("приостановка около 500 мс: ${suspending < 900}")     // приостановка около 500 мс: true
}

Thread.sleep держит поток; delay возвращает его в пул. Из-за этого четыре delay(500) на одном потоке занимают 500 мс, а четыре Thread.sleep(500) - столько, сколько позволит число потоков.

Как это работает под капотом

Компилятор превращает suspend-функцию в машину состояний с объектом-продолжением (Continuation). Каждая точка приостановки - это метка, куда вернётся выполнение.

graph LR
    A["вызов suspend-функции"] --> B["выполнение до delay"]
    B --> C{"нужна приостановка?"}
    C -->|"нет, результат готов"| G["вернуть значение"]
    C -->|"да"| D["сохранить состояние<br/>в Continuation"]
    D --> E["вернуть COROUTINE_SUSPENDED<br/>поток свободен"]
    E --> F["внешнее событие:<br/>таймер, I/O, другая корутина"]
    F --> H["resume: восстановить состояние"]
    H --> B

Практическое следствие: приостановка возможна только в suspend-функции, потому что только у неё есть скрытый параметр Continuation. Подробнее: Suspend-функции

Параллелизм

Последовательный код легко превратить в параллельный:

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

suspend fun fetchA(): Int { delay(200); return 1 }
suspend fun fetchB(): Int { delay(200); return 2 }

fun main() = runBlocking {
    val sequential = measureTimeMillis {
        val sum = fetchA() + fetchB()
        println("последовательно: $sum")        // последовательно: 3
    }

    val parallel = measureTimeMillis {
        val a = async { fetchA() }
        val b = async { fetchB() }
        println("параллельно: ${a.await() + b.await()}")      // параллельно: 3
    }

    println("ускорение примерно вдвое: ${sequential > parallel + 100}")   // ускорение примерно вдвое: true
}

Разница - в async: он запускает корутину и сразу возвращает Deferred, а await ждёт результат. Подробнее: Билдеры корутин

Структурированная конкурентность

Главное архитектурное решение библиотеки: у каждой корутины есть родитель, и родитель не завершится раньше детей.

import kotlinx.coroutines.*

fun main() = runBlocking {
    println("начало")
    coroutineScope {
        launch { delay(100); println("ребёнок 1") }
        launch { delay(50); println("ребёнок 2") }
    }
    println("конец — после всех детей")
    // начало
    // ребёнок 2
    // ребёнок 1
    // конец — после всех детей
}

Из этого бесплатно получаются: ожидание завершения, распространение отмены вниз и ошибок вверх, отсутствие «потерянных» задач. Это то, чего в Java приходится добиваться вручную. Подробнее: Структурированная конкурентность

Карта раздела

Заметка О чём
Suspend-функции suspend, Continuation, машина состояний
Структурированная конкурентность CoroutineScope, иерархия, жизненный цикл
Билдеры корутин launch, async, runBlocking, withContext
Диспетчеры и контекст на каком потоке выполняется код
Отмена и таймауты кооперативная отмена, withTimeout, NonCancellable
Исключения в корутинах распространение ошибок, SupervisorJob, обработчики
Flow асинхронный поток значений, операторы, backpressure
StateFlow и SharedFlow горячие потоки и состояние
Channels передача значений между корутинами
Разделяемое состояние Mutex, атомарные типы, конфайнмент
Корутины против потоков цифры, стоимость, когда нужны потоки

Подключение

Корутины - не часть stdlib, это отдельная библиотека:

// build.gradle.kts
dependencies {
    implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.11.0")
}

В языке есть только suspend, Continuation и suspendCoroutine - минимальная основа. launch, async, Flow, диспетчеры и каналы живут в kotlinx.coroutines. Подробнее: Gradle и Kotlin DSL

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

Java Kotlin
Thread, ExecutorService корутины на пуле потоков
CompletableFuture<T> Deferred<T> или просто suspend fun
.thenApply().thenCompose() последовательный код
CountDownLatch, Future.get() join(), await()
виртуальные потоки (Java 21+) корутины
Flow из Reactive Streams kotlinx.coroutines.Flow
отмена через Future.cancel и флаги отмена по иерархии

Главное отличие - асинхронный код выглядит последовательным. В Java до виртуальных потоков асинхронность означала коллбэки или цепочки CompletableFuture:

loadUser(id)
    .thenCompose(user -> loadOrders(user.getId()))
    .thenApply(orders -> format(orders))
    .exceptionally(e -> "ошибка: " + e.getMessage());

Каждый шаг - лямбда, переменные приходится протаскивать через замыкания, а try/catch, циклы и if в такой цепочке выражаются неудобно. В Kotlin то же самое пишется обычным кодом:

import kotlinx.coroutines.*

suspend fun loadUser2(id: Int): String { delay(10); return "user$id" }
suspend fun loadOrders2(user: String): List<String> { delay(10); return listOf("$user-заказ") }

suspend fun report(id: Int): String = try {
    val user = loadUser2(id)
    val orders = loadOrders2(user)
    orders.joinToString()
} catch (e: Exception) {
    "ошибка: ${e.message}"
}

fun main() = runBlocking { println(report(1)) }      // user1-заказ

Работают обычные try/catch, for, return и локальные переменные.

Виртуальные потоки (Java 21) решают ту же задачу иначе. Project Loom делает блокировку дешёвой: виртуальный поток при блокирующем вызове освобождает несущий поток ОС, поэтому старый блокирующий код внезапно масштабируется. Разница в подходе: Loom прячет асинхронность от программиста (Thread.sleep просто становится дешёвым), корутины делают её видимой в типах (suspend в сигнатуре). Плюс Loom - работает весь существующий код; плюс корутин - компилятор знает, где функция может приостановиться, и из этого вырастают структурированная конкурентность, Flow и отмена. Корутины также работают на Android и в Kotlin/Native, где Loom недоступен.

Отмена в Java не распространяется. Future.cancel(true) ставит флаг прерывания одному потоку; задачи, которые он породил, продолжат работу. В Kotlin отмена идёт по дереву корутин автоматически. Для Java это пришлось бы строить руками поверх ExecutorService. Подробнее: Отмена и таймауты

Реактивные библиотеки закрывают часть задач. RxJava и Project Reactor дают потоки данных и операторы, то есть примерный аналог Flow. Но у них свой стиль (всё - цепочка операторов), высокий порог входа и плохие стектрейсы. Flow при этом - обычные suspend-функции внутри, поэтому в нём можно писать for и try.

Сравнить: Многопоточность в Java, JVM

[!warning] Подводные камни Корутины не ускоряют вычисления на CPU. Они экономят потоки при ожидании (сеть, диск, таймеры). Если код считает, а не ждёт, выгоды от корутин нет - там нужен параллелизм по числу ядер:

import kotlinx.coroutines.*

fun main() = runBlocking {
    val result = withContext(Dispatchers.Default) {       // пул по числу ядер
        (1..4).map { async { (1..1_000_000).sumOf { x -> x.toLong() } } }.awaitAll()
    }
    println(result.size)        // 4
}

На Dispatchers.IO такой код только создаст лишние потоки. Подробнее: Диспетчеры и контекст

Блокирующий вызов внутри корутины блокирует поток целиком:

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

fun main() = runBlocking {
    val t = measureTimeMillis {
        withContext(Dispatchers.Default.limitedParallelism(1)) {
            List(3) { launch { Thread.sleep(100) } }.forEach { it.join() }
        }
    }
    println("последовательно, не 100 мс: ${t >= 300}")      // последовательно, не 100 мс: true
}

Thread.sleep, JDBC, старый HttpURLConnection, File.readText - всё это блокирует. Такие вызовы нужно оборачивать в withContext(Dispatchers.IO).

runBlocking блокирует вызывающий поток - это мост из обычного кода в корутины, а не способ запускать их в продакшене. В Android и серверных фреймворках у вас уже есть scope; runBlocking уместен в main, тестах и утилитах.

Корутина без scope теряется. GlobalScope.launch не привязан ни к чему: его никто не отменит и никто не узнает об ошибке. Это главный антипаттерн в корутинах. Подробнее: Структурированная конкурентность

Исключение в launch не ловится снаружи:

import kotlinx.coroutines.*

fun main() = runBlocking {
    try {
        launch { throw IllegalStateException("внутри launch") }
    } catch (e: Exception) {
        println("не поймается здесь")
    }
    delay(50)
    println("а это выполнится")
}

Этот код напечатает а это выполнится, а затем уронит программу: launch передаёт ошибку родителю, а не выбрасывает её в месте вызова. Подробнее: Исключения в корутинах

Отмена кооперативная. Корутина, которая не вызывает suspend-функций и не проверяет isActive, не отменяется:

import kotlinx.coroutines.*

fun main() = runBlocking {
    val job = launch(Dispatchers.Default) {
        var i = 0
        while (i < 100_000_000) i++          // не проверяет отмену
        println("досчитал несмотря на отмену")
    }
    delay(20)
    job.cancelAndJoin()
    println("cancel вернулся")
}

Цикл без точек приостановки игнорирует cancel.

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

Версия библиотеки имеет значение. API корутин развивается: limitedParallelism, Flow-операторы, SupervisorJob поведение менялись между версиями. Код из статей 2019 года может не компилироваться или вести себя иначе.

Далее: Suspend-функции