Files
obsidian/Kotlin/Корутины/Suspend-функции.md
T

21 KiB
Raw Blame History

title, description, tags
title description tags
Suspend-функции Модификатор suspend, Continuation, машина состояний и мост к колбэчным API
kotlin
coroutines

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(): T
void f(Callback<T> cb) suspend fun f(): T
f().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, не давая ничего взамен. Компилятор предупреждает; предупреждение стоит слушать.

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