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

396 lines
21 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
title: Suspend-функции
description: Модификатор suspend, Continuation, машина состояний и мост к колбэчным API
tags:
- kotlin
- coroutines
---
`suspend` означает: эта функция может приостановиться, поэтому вызывать её разрешено только оттуда, где приостановка возможна.
```kotlin
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` превращается в метод с дополнительным параметром:
```kotlin
// примерно так это выглядит с точки зрения Java:
// Object fetchData(Continuation<? super String> continuation)
```
`Continuation` - это «что делать дальше»: объект с методом `resumeWith`, хранящий сохранённое состояние функции.
```kotlin
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. Компилятор делает это преобразование автоматически, поэтому в исходнике остаётся обычный последовательный код.
## Машина состояний
Каждая точка приостановки превращается в метку состояния:
```kotlin
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` не обязывает приостанавливаться:
```kotlin
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` без точек приостановки:
```kotlin
suspend fun noSuspension(): Int = 42 // warning: redundant 'suspend' modifier
fun main() {
println("нужен вызов из корутины или runBlocking")
}
```
Такой модификатор стоит убрать - он заставляет вызывающих быть `suspend` без причины.
## suspend-лямбды и типы
```kotlin
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`-функции - основной способ интегрировать существующие библиотеки.
```kotlin
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`.
Отмену нужно поддержать явно:
```kotlin
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` - то, что отличает корректную обёртку от утечки: при отмене корутины нижележащий запрос тоже нужно отменить. Подробнее: [Отмена и таймауты](Отмена%20и%20таймауты.md)
## Последовательность и параллельность
`suspend`-функции по умолчанию выполняются последовательно:
```kotlin
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`. Подробнее: [Билдеры корутин](Билдеры%20корутин.md)
## Правила проектирования suspend-API
| Правило | Почему |
|---|---|
| `suspend fun` не должна блокировать поток | иначе теряется весь смысл |
| смена диспетчера - внутри функции, а не у вызывающего | вызывающий не должен знать, I/O это или CPU |
| функция должна быть безопасна для отмены | иначе отмена не работает или течёт |
| возвращать значение, а не `Deferred` | параллельность - дело вызывающего |
| `suspend` + возврат `Flow` обычно излишни | `Flow` сам по себе холодный |
Правило про диспетчер - самое практичное:
```kotlin
import kotlinx.coroutines.*
// хорошо: вызывающему не нужно думать о потоках
suspend fun readConfig(): String = withContext(Dispatchers.IO) {
"содержимое файла" // здесь был бы блокирующий File.readText
}
fun main() = runBlocking {
println(readConfig()) // содержимое файла
}
```
Такая функция называется main-safe: её можно вызвать с любого потока, включая UI.
## suspend и наследование
```kotlin
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`), а асинхронность выражается модификатором:
> ```kotlin
> 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](../Взаимодействие%20с%20Java/Kotlin%20из%20Java.md)
>
> **Виртуальные потоки убирают необходимость в модификаторе.** В Java 21 блокирующий `loadOne(id)` на виртуальном потоке масштабируется без изменения сигнатуры - это главный плюс Loom: существующий код работает как есть. Цена - компилятор не знает, где функция блокируется, поэтому нет ни структурированной отмены по дереву, ни проверки «не блокируй UI-поток» на этапе компиляции.
>
> Сравнить: [Многопоточность в Java](../../Программирование%20на%20Java/Многопоточность/Взаимодейтсвие%20между%20потоками.md), [Функции в Java](../../Программирование%20на%20Java/Синтаксис%20и%20типы/Работа%20с%20данными%20и%20типами/Функции.md)
> [!warning] Подводные камни
> **Блокировка внутри `suspend` сводит корутины на нет:**
> ```kotlin
> 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` бросает исключение:**
> ```kotlin
> 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` игнорирует отмену:**
> ```kotlin
> 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`:**
> ```kotlin
> 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`-функция создаёт объект продолжения на каждый кадр:**
> ```kotlin
> 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`, не давая ничего взамен. Компилятор предупреждает; предупреждение стоит слушать.
Далее: [Структурированная конкурентность](Структурированная%20конкурентность.md)