18 KiB
title, description, tags
| title | description | tags | ||
|---|---|---|---|---|
| Корутины | Что такое приостанавливаемое вычисление, зачем оно нужно и как устроен раздел |
|
Корутина - это вычисление, которое можно приостановить и продолжить позже, не блокируя поток. Это главный инструмент 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 Streamskotlinx.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-функции