Лекции

Senior

Основы Kotlin Coroutines

Базовая модель Kotlin Coroutines: приостановка, scope, dispatcher, structured concurrency, отмена и Android lifecycle.

coroutinesstructured-concurrencycancellationdispatchers
На этой странице

Это русскоязычная подготовленная версия предоставленного конспекта. Все исходные кодовые блоки сохранены как read-only примеры. Примеры не компилировались и не запускались в ходе подготовки.

Теория

Зачем нужны Coroutines

Обычный блокирующий код:

fun loadUser(): User {
    val response = networkCall()
    return response.toUser()
}

Если выполнить blocking-вызов на Main Thread, UI не сможет нормально обрабатывать ввод, рисовать кадры и выполнять callbacks.

Coroutines позволяют писать последовательный по форме код:

val user = repository.loadUser()
showUser(user)

при этом loadUser() может быть suspend-функцией:

suspend fun loadUser(): User

Главный принцип:

Suspension ≠ blocking.

Официальная документация: Документация Kotlin Coroutines.

Coroutine ≠ Thread

Coroutine — вычисление, которое можно приостановить и затем продолжить.

Thread — ресурс ОС, на котором выполняются инструкции.

Thread-1:

Coroutine A ────────┐
                    │ suspend
Coroutine B         ├───────────────>
                    │
Coroutine C ────────┘

Можно иметь огромное количество coroutine поверх относительно небольшого количества threads.

Но coroutine всё равно исполняется на каком-то thread.

Официальная документация: Документация Kotlin Coroutines.

Что значит suspend

suspend fun loadUser(): User {
    delay(1000)
    return User()
}

suspend не означает background thread.

Он означает, что функция может приостановить coroutine и позже продолжить её.

println("A")
     │
     ▼
delay()
     │
     ├── coroutine suspended
     └── thread свободен
              │
              ▼
        coroutine resumed
              │
              ▼
        println("B")

Сравнение:

Thread.sleep:
Thread ───────── BLOCKED ─────────>

delay:
Coroutine ── suspend ───────── resume
Thread    ── свободен ─────────────>

Официальная документация: Документация Kotlin Coroutines.

Как suspend работает внутри

Компилятор преобразует suspend-функцию в state machine.

suspend fun foo() {
    println("A")
    delay(1000)
    println("B")
}

Концептуально становится чем-то вроде:

fun foo(continuation: Continuation<Unit>): Any

State machine хранит точку продолжения:

label = 0
→ println("A")
→ delay()
→ suspend

label = 1
→ println("B")

Coroutine — это не замороженный thread. Сохраняется состояние вычисления.

Официальная документация: Документация Kotlin Coroutines.

Continuation

В основе suspend лежит:

interface Continuation<in T> {
    val context: CoroutineContext
    fun resumeWith(result: Result<T>)
}

Continuation отвечает на вопрос:

Что делать дальше, когда suspend operation завершится?

Официальная документация: Документация Kotlin Coroutines.

launch

val job: Job = scope.launch {
    repository.sync()
}

launch возвращает Job и используется, когда отдельный возвращаемый результат не нужен.

Официальная документация: Job API.

async

val deferred: Deferred<User> = scope.async {
    repository.loadUser()
}

val user = deferred.await()

Ментально:

Deferred<T> ≈ Job + Result<T>

Плохая «параллельность»:

val user = async { loadUser() }.await()
val posts = async { loadPosts() }.await()

Фактически операции последовательны.

Правильно для независимых задач:

val user = async { loadUser() }
val posts = async { loadPosts() }

val result = UserPage(
    user = user.await(),
    posts = posts.await(),
)

Официальная документация: Job API.

runBlocking

runBlocking {
    doSomething()
}

runBlocking реально блокирует текущий thread.

В Android UI-коде это почти всегда подозрительно. Полезен прежде всего как bridge между blocking и coroutine world; в тестах обычно предпочтительнее runTest.

Официальная документация: Тестирование coroutine.

CoroutineScope

Coroutine запускается внутри CoroutineScope.

CoroutineScope
     │
     ▼
CoroutineContext
     │
     ├── Job
     ├── Dispatcher
     ├── CoroutineName
     └── ExceptionHandler

Пример:

val scope = CoroutineScope(
    SupervisorJob() +
        Dispatchers.IO +
        CoroutineName("SyncScope")
)

Официальная документация: CoroutineScope API.

CoroutineContext

CoroutineContext — набор элементов.

Job             -> SupervisorJob
Dispatcher      -> Dispatchers.IO
CoroutineName   -> "Loader"
Handler         -> ...

Оператор + собирает единый context из элементов.

Официальная документация: Dispatchers API.

Dispatchers

Основные:

Dispatchers.Main
Dispatchers.IO
Dispatchers.Default
Dispatchers.Unconfined

Ментальная модель:

Main      → UI
IO        → blocking I/O
Default   → CPU work

Dispatchers.IO — для blocking I/O, а не для любой операции, связанной с сетью или диском концептуально.

Dispatchers.Default — CPU-intensive работа: сортировки, тяжёлый parsing, image processing, cryptography, game-tree search.

Официальная документация: Dispatchers API.

Почему Retrofit suspend API обычно не требует Dispatchers.IO

@GET("users")
suspend fun users(): List<User>

Асинхронный HTTP-клиент не обязан блокировать Main Thread в ожидании сети.

Main
 │
 ▼
api.users()
 │
 ├── suspend
 └── HTTP выполняется асинхронно
          │
          ▼
       response
          │
          ▼
       resume

Dispatchers.IO нужен для blocking API.

Официальная документация: Документация Kotlin Coroutines.

withContext

val result = withContext(Dispatchers.IO) {
    readFile()
}

withContext меняет часть coroutine context, suspend'ит caller до завершения блока и возвращает результат.

Важно: coroutine логически остаётся той же, физический thread может поменяться.

Официальная документация: Документация Kotlin Coroutines.

Structured concurrency

coroutineScope {
    launch { taskA() }
    launch { taskB() }
}

Формируется дерево:

Parent Job
│
├── Child A
└── Child B

Parent знает о children и не считается завершённым, пока children не завершились.

Официальная документация: Job API.

Job hierarchy

viewModelScope Job
│
└── Parent coroutine
    │
    ├── taskA Job
    └── taskB Job

Это определяет lifecycle, cancellation и exception propagation.

GlobalScope отрывает работу от естественного lifecycle owner и поэтому почти всегда является smell.

Официальная документация: Job API.

Cancellation

Cancellation cooperative.

val job = scope.launch {
    while (isActive) {
        calculate()
    }
}

job.cancel()

cancel() не убивает thread. Coroutine должна заметить cancellation.

Cancellation обычно проверяется в:

delay()
yield()
await()
join()

или вручную:

ensureActive()

CancellationException

При отмене coroutine используется CancellationException.

Опасно:

try {
    load()
} catch (e: Exception) {
    log(e)
}

Можно случайно проглотить cancellation.

Безопаснее:

try {
    load()
} catch (e: CancellationException) {
    throw e
} catch (e: Exception) {
    handle(e)
}

runCatching и cancellation

runCatching ловит Throwable, поэтому при suspend-коде важно не превратить cancellation в обычный Result.failure.

suspend inline fun <T> runCatchingCancellable(
    crossinline block: suspend () -> T
): Result<T> =
    try {
        Result.success(block())
    } catch (e: CancellationException) {
        throw e
    } catch (e: Throwable) {
        Result.failure(e)
    }

Официальная документация: Документация Kotlin Coroutines.

coroutineScope

suspend fun loadScreen(): Screen =
    coroutineScope {
        val user = async { loadUser() }
        val posts = async { loadPosts() }

        Screen(
            user.await(),
            posts.await(),
        )
    }

Если один child падает, обычный coroutineScope отменяет siblings и пробрасывает failure caller'у.

Официальная документация: Документация Kotlin Coroutines.

supervisorScope

supervisorScope {
    launch { loadAds() }
    launch { loadRecommendations() }
}

Failure одного child не обязан отменять sibling.

Job vs SupervisorJob

Job
│
├── Child A ❌
└── Child B → cancelled
SupervisorJob
│
├── Child A ❌
└── Child B → continues

Supervision изолирует failure, но не «обрабатывает» exception автоматически.

Официальная документация: Job API.

Exceptions: launch vs async

launch представляет Job; необработанный failure распространяется через Job hierarchy.

async представляет Deferred<T>; await() возвращает T или выбрасывает сохранённый failure.

Но child async всё равно участвует в structured concurrency и может отменить parent ещё до await().

Официальная документация: Job API.

CoroutineExceptionHandler

val handler = CoroutineExceptionHandler { _, throwable ->
    log(throwable)
}

Это не универсальный try/catch. Он предназначен прежде всего для uncaught/root coroutine exceptions.

Expected business failures обычно обрабатываются локально.

viewModelScope

class UserViewModel(
    private val repository: UserRepository
) : ViewModel() {
    fun load() {
        viewModelScope.launch {
            val user = repository.load()
        }
    }
}

Когда ViewModel очищается, его scope отменяется вместе с дочерними coroutine.

lifecycleScope и repeatOnLifecycle

lifecycleScope.launch {
    repeatOnLifecycle(Lifecycle.State.STARTED) {
        flow.collect {
            render(it)
        }
    }
}
STARTED → start collecting
STOPPED → cancel collecting
STARTED → start collecting again

Официальная документация: repeatOnLifecycle.

Coroutines ≠ thread safety

var counter = 0

repeat(1000) {
    launch(Dispatchers.Default) {
        counter++
    }
}

counter++ не atomic.

Coroutines сами по себе не дают synchronization.

Официальная документация: Dispatchers API.

Mutex

val mutex = Mutex()

mutex.withLock {
    sharedState++
}

Ожидающая lock coroutine может suspend'иться, а не блокировать thread.

Официальная документация: Документация Kotlin Coroutines.

Concurrency vs Parallelism

Concurrency — несколько задач находятся в процессе выполнения.

Parallelism — несколько задач физически исполняются одновременно на разных cores/threads.

Coroutines дают удобную concurrency model; parallelism зависит от dispatcher и hardware.

Main-safety

Хорошая suspend-функция data layer должна быть безопасна для вызова с Main:

suspend fun loadUser(): User =
    withContext(ioDispatcher) {
        blockingDatabase.loadUser()
    }

Caller не должен знать детали blocking/non-blocking implementation.

Официальная документация: Документация Kotlin Coroutines.

Dispatcher injection

class Repository(
    private val ioDispatcher: CoroutineDispatcher
) {
    suspend fun load() =
        withContext(ioDispatcher) {
            blockingCall()
        }
}

Это улучшает тестируемость.

Официальная документация: Документация Kotlin Coroutines.

Практика

Вопросы для разбора

Для каждого примера нарисуйте иерархию Job, определите CoroutineContext и Dispatcher, отметьте точки suspension/cancellation, затем проследите failure вверх и cancellation вниз. Для Flow дополнительно определите cold/hot semantics, владельца collection, поведение медленного consumer, replay и последствия lifecycle stop/start.

Проверка архитектурного решения

Спросите: кто владеет этой работой, кто может её отменить, где обрабатывается ошибка, нужна ли каждая emission или только latest, и переживает ли результат пересоздание UI. Coroutine не переживает process death; для гарантированной фоновой работы нужен durable scheduler, например WorkManager.

Дополнительные материалы