21 KiB
title, description, tags
| title | description | tags | ||
|---|---|---|---|---|
| Suspend-функции | Модификатор suspend, Continuation, машина состояний и мост к колбэчным API |
|
suspend означает: эта функция может приостановиться, поэтому вызывать её разрешено только оттуда, где приостановка возможна.
import kotlinx.coroutines.*
suspend fun fetchData(): String {
delay(100) // точка приостановки
return "данные"
}
fun main() = runBlocking {
println(fetchData()) // данные
}
Вне корутины вызов не скомпилируется: fun main() { fetchData() } даёт «Suspend function can only be called within coroutine body».
Что добавляет компилятор
suspend fun fetchData(): String превращается в метод с дополнительным параметром:
// примерно так это выглядит с точки зрения Java:
// Object fetchData(Continuation<? super String> continuation)
Continuation - это «что делать дальше»: объект с методом resumeWith, хранящий сохранённое состояние функции.
import kotlin.coroutines.*
fun main() {
val continuation = object : Continuation<String> {
override val context = EmptyCoroutineContext
override fun resumeWith(result: Result<String>) {
println("получено: ${result.getOrNull()}")
}
}
continuation.resumeWith(Result.success("значение")) // получено: значение
}
Отсюда и название CPS - continuation-passing style. Компилятор делает это преобразование автоматически, поэтому в исходнике остаётся обычный последовательный код.
Машина состояний
Каждая точка приостановки превращается в метку состояния:
import kotlinx.coroutines.*
suspend fun threeSteps(): Int {
println("шаг 1") // состояние 0
delay(10)
println("шаг 2") // состояние 1
delay(10)
println("шаг 3") // состояние 2
return 42
}
fun main() = runBlocking {
println(threeSteps())
// шаг 1
// шаг 2
// шаг 3
// 42
}
В байт-коде это один метод с switch по полю label: при каждом возобновлении он входит в себя заново и прыгает к нужной метке. Локальные переменные, живущие между приостановками, становятся полями объекта-продолжения.
Практические следствия:
- приостановка стоит дешевле переключения потока, но дороже обычного вызова
- глубоко вложенные
suspend-вызовы создают по объекту продолжения на кадр - стектрейс восстанавливается библиотекой, а не JVM - поэтому он может быть короче ожидаемого
Функция, которая не приостанавливается
suspend не обязывает приостанавливаться:
import kotlinx.coroutines.*
suspend fun maybeSlow(cached: String?): String {
cached?.let { return it } // быстрый путь: без приостановки
delay(100)
return "загружено"
}
fun main() = runBlocking {
println(maybeSlow("из кеша")) // из кеша
println(maybeSlow(null)) // загружено
}
Если приостановки не произошло, функция возвращает значение напрямую - без накладных расходов. Это важно для «горячих» путей: suspend не означает «всегда медленно».
Компилятор предупреждает о suspend без точек приостановки:
suspend fun noSuspension(): Int = 42 // warning: redundant 'suspend' modifier
fun main() {
println("нужен вызов из корутины или runBlocking")
}
Такой модификатор стоит убрать - он заставляет вызывающих быть suspend без причины.
suspend-лямбды и типы
import kotlinx.coroutines.*
suspend fun retry(times: Int, block: suspend () -> String): String {
repeat(times - 1) { attempt ->
try {
return block()
} catch (e: Exception) {
println("попытка ${attempt + 1}: ${e.message}")
delay(10)
}
}
return block()
}
fun main() = runBlocking {
var counter = 0
val result = retry(3) {
counter++
delay(5)
if (counter < 3) error("не готово") else "успех"
}
println(result)
// попытка 1: не готово
// попытка 2: не готово
// успех
}
Тип suspend () -> String - функциональный тип, внутри которого можно приостанавливаться. Без suspend в типе параметра delay внутри лямбды не скомпилируется.
Для лямбды с получателем аналогично: suspend CoroutineScope.() -> Unit - именно так объявлены launch и runBlocking.
Приостановка без блокировки: suspendCancellableCoroutine
Это мост от колбэчного API к suspend-функции - основной способ интегрировать существующие библиотеки.
import kotlinx.coroutines.*
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
// колбэчный API, который нужно обернуть
fun legacyLoad(onSuccess: (String) -> Unit, onError: (Throwable) -> Unit) {
Thread {
Thread.sleep(50)
onSuccess("результат из колбэка")
}.start()
}
suspend fun load(): String = suspendCancellableCoroutine { cont ->
legacyLoad(
onSuccess = { cont.resume(it) },
onError = { cont.resumeWithException(it) },
)
}
fun main() = runBlocking {
println(load()) // результат из колбэка
}
Правила: resume или resumeWithException нужно вызвать ровно один раз. Повторный вызов бросит IllegalStateException.
Отмену нужно поддержать явно:
import kotlinx.coroutines.*
import java.util.concurrent.CompletableFuture
import kotlin.coroutines.resume
suspend fun awaitFuture(future: CompletableFuture<String>): String =
suspendCancellableCoroutine { cont ->
future.whenComplete { value, error ->
if (error != null) cont.cancel(error) else cont.resume(value)
}
cont.invokeOnCancellation { future.cancel(true) } // освобождаем ресурс
}
fun main() = runBlocking {
val future = CompletableFuture.supplyAsync {
Thread.sleep(30)
"из future"
}
println(awaitFuture(future)) // из future
}
invokeOnCancellation - то, что отличает корректную обёртку от утечки: при отмене корутины нижележащий запрос тоже нужно отменить. Подробнее: Отмена и таймауты
Последовательность и параллельность
suspend-функции по умолчанию выполняются последовательно:
import kotlinx.coroutines.*
import kotlin.system.measureTimeMillis
suspend fun step(name: String): String { delay(100); return name }
fun main() = runBlocking {
val sequential = measureTimeMillis {
val a = step("a")
val b = step("b")
println("$a$b") // ab
}
println("последовательно около 200 мс: ${sequential >= 200}") // последовательно около 200 мс: true
val parallel = measureTimeMillis {
coroutineScope {
val a = async { step("a") }
val b = async { step("b") }
println("${a.await()}${b.await()}") // ab
}
}
println("параллельно около 100 мс: ${parallel < 180}") // параллельно около 100 мс: true
}
Это сознательное решение: последовательность - разумное поведение по умолчанию, параллельность требуется явно запросить через async. Подробнее: Билдеры корутин
Правила проектирования suspend-API
| Правило | Почему |
|---|---|
suspend fun не должна блокировать поток |
иначе теряется весь смысл |
| смена диспетчера - внутри функции, а не у вызывающего | вызывающий не должен знать, I/O это или CPU |
| функция должна быть безопасна для отмены | иначе отмена не работает или течёт |
возвращать значение, а не Deferred |
параллельность - дело вызывающего |
suspend + возврат Flow обычно излишни |
Flow сам по себе холодный |
Правило про диспетчер - самое практичное:
import kotlinx.coroutines.*
// хорошо: вызывающему не нужно думать о потоках
suspend fun readConfig(): String = withContext(Dispatchers.IO) {
"содержимое файла" // здесь был бы блокирующий File.readText
}
fun main() = runBlocking {
println(readConfig()) // содержимое файла
}
Такая функция называется main-safe: её можно вызвать с любого потока, включая UI.
suspend и наследование
import kotlinx.coroutines.*
interface Repository {
suspend fun find(id: Int): String?
}
class RemoteRepository : Repository {
override suspend fun find(id: Int): String? {
delay(10)
return if (id == 1) "найдено" else null
}
}
class CachedRepository : Repository {
private val cache = mapOf(1 to "из кеша")
override suspend fun find(id: Int): String? = cache[id] // без приостановки
}
fun main() = runBlocking {
println(RemoteRepository().find(1)) // найдено
println(CachedRepository().find(1)) // из кеша
println(CachedRepository().find(2)) // null
}
suspend в интерфейсе - правильный способ объявить операцию, которая может быть асинхронной. Реализация решает, приостанавливаться ли. Обратное невозможно: не-suspend метод интерфейса нельзя переопределить как suspend.
[!info] Отличия от Java
Java Kotlin CompletableFuture<T> f()suspend fun f(): Tvoid f(Callback<T> cb)suspend fun f(): Tf().thenApply(x -> ...)val x = f()CompletionStageв сигнатуреsuspendв модификатореблокирующий f()+ пул потоковsuspend+withContext(IO)нет аналога ContinuationАсинхронность в типе возврата против модификатора. В Java асинхронная функция возвращает обёртку:
CompletableFuture<User>. Это заражает тип:List<CompletableFuture<User>>приходится разворачивать черезallOf, а вложенные вызовы даютCompletableFuture<CompletableFuture<T>>иthenComposeвместоthenApply. В Kotlin тип возврата остаётся обычным (User), а асинхронность выражается модификатором:import kotlinx.coroutines.* suspend fun loadOne(id: Int): String { delay(10); return "u$id" } suspend fun loadMany(ids: List<Int>): List<String> = coroutineScope { ids.map { async { loadOne(it) } }.awaitAll() } fun main() = runBlocking { println(loadMany(listOf(1, 2, 3))) } // [u1, u2, u3]В Java это
CompletableFuture.allOf(...)с ручной сборкой результатов и приведением типов.
Continuation- это обобщённый коллбэк. В сущностиsuspend fun f(): Tкомпилируется вf(Continuation<T>), то есть в тот же колбэчный стиль, который в Java пишут руками. Разница в том, что преобразование делает компилятор, поэтому исходник остаётся линейным, а в Java программист пишет лямбды сам и получает «callback hell» при вложенности.Из Java
suspend-функцию вызвать неудобно. Она видна как метод с лишним параметромContinuation, возвращающийObject. Для интеграции есть готовые мосты:future { }изkotlinx-coroutines-jdk8возвращаетCompletableFuture, аrunBlockingдаёт синхронный вызов. Подробнее: Kotlin из JavaВиртуальные потоки убирают необходимость в модификаторе. В Java 21 блокирующий
loadOne(id)на виртуальном потоке масштабируется без изменения сигнатуры - это главный плюс Loom: существующий код работает как есть. Цена - компилятор не знает, где функция блокируется, поэтому нет ни структурированной отмены по дереву, ни проверки «не блокируй UI-поток» на этапе компиляции.Сравнить: Многопоточность в Java, Функции в Java
[!warning] Подводные камни Блокировка внутри
suspendсводит корутины на нет:import kotlinx.coroutines.* // плохо: функция объявлена suspend, но блокирует поток suspend fun badRead(): String { Thread.sleep(50) return "заблокировал поток" } // хорошо: блокирующий код в подходящем диспетчере suspend fun goodRead(): String = withContext(Dispatchers.IO) { Thread.sleep(50) "поток из IO-пула" } fun main() = runBlocking { println(badRead()) println(goodRead()) // заблокировал поток // поток из IO-пула }Компилятор это не поймает:
suspendне запрещает блокировку. Ошибка проявится как «приложение зависает под нагрузкой».Повторный
resumeбросает исключение:import kotlinx.coroutines.* import kotlin.coroutines.resume suspend fun broken(): String = suspendCancellableCoroutine { cont -> cont.resume("первый") // cont.resume("второй") // IllegalStateException: Already resumed } fun main() = runBlocking { println(broken()) } // первыйКолбэчные API, которые могут вызвать коллбэк дважды (например, при ретраях внутри), требуют защиты флагом или
isActive-проверки.Обёртка без
invokeOnCancellationтечёт. При отмене корутины нижележащий запрос продолжит работу: соединение останется открытым, таймер - запущенным, файл - читаемым. Всегда освобождайте ресурс вinvokeOnCancellation.
suspendCoroutineбезCancellableигнорирует отмену:import kotlinx.coroutines.* fun main() = runBlocking { val job = launch { try { withTimeout(30) { delay(1000) } } catch (e: TimeoutCancellationException) { println("таймаут сработал: ${e::class.simpleName}") } } job.join() }
delayотменяем, поэтому таймаут работает. А вотsuspendCoroutine { }(безCancellable) приостановится навсегда и таймаут его не прервёт - используйтеsuspendCancellableCoroutine.
suspendподнимается по всему стеку вызовов. Добавить приостановку в глубоко вложенную функцию означает помечатьsuspendвсю цепочку вверх. Если наверху - не-suspend интерфейс (например,Comparatorилиequals), сделать это невозможно, и придётся либо менять архитектуру, либо блокировать черезrunBlocking.
suspendнельзя использовать в конструкторе, геттере свойства иinit:import kotlinx.coroutines.* class Service { // suspend init — невозможно // val value: String get() = loadValue() // геттер не может быть suspend suspend fun loadValue(): String { delay(10); return "значение" } } fun main() = runBlocking { println(Service().loadValue()) } // значениеДля асинхронной инициализации используют отдельную
suspend fun init()илиlazy-обёртку черезDeferred.Нельзя переопределить обычный метод как
suspend. Если интерфейс объявлен в Java (Runnable,Callable,Comparator), асинхронность туда не поместится - толькоrunBlockingвнутри, что блокирует поток.Стектрейсы обрезаны. Исключение из корутины содержит кадры только текущей точки возобновления, а не всю логическую цепочку. Для отладки включают
-Dkotlinx.coroutines.debugили добавляютkotlinx-coroutines-debug, которые восстанавливают историю приостановок.Рекурсивная
suspend-функция создаёт объект продолжения на каждый кадр:import kotlinx.coroutines.* suspend fun countDown(n: Int): Int = if (n <= 0) 0 else countDown(n - 1) + 1 fun main() = runBlocking { println(countDown(1000)) // 1000 }Глубина ограничена не стеком JVM, а памятью, но на десятках тысяч кадров это заметная аллокация. Хвостовая рекурсия (
tailrec) сsuspendне сочетается.
suspendбез приостановок - лишний. Он заставляет всех вызывающих бытьsuspend, не давая ничего взамен. Компилятор предупреждает; предупреждение стоит слушать.