JavaRush /Курсы /Kotlin SELF /CoroutineScope и Job — иерархия задач и управление жизнен...

CoroutineScope и Job — иерархия задач и управление жизненным циклом

Kotlin SELF
53 уровень , 0 лекция
Открыта

1. Введение

Когда начинаешь писать корутины, в голове быстро появляется мысль: «О, круто! Сейчас я всё распараллелю!» — и это нормальная стадия. Но очень скоро всплывает вопрос, который звучит скучно, но спасает нервы: а кто отвечает за эти задачи и когда они должны закончиться?

Представьте, что ваша программа — это кухня. main() — это вы, а корутины — повара-помощники. Если вы просто крикнули «Эй, кто-нибудь нарежьте овощи!» и ушли в магазин, то, вернувшись, вы не знаете: овощи уже нарезаны, помощник устал и ушёл, или он всё ещё режет помидор в одиночестве и плачет. Structured concurrency — это правило «повара работают в рамках смены»: если закончилась смена (операция), все помощники должны завершить свою работу, и кухня должна перейти в понятное состояние.

В Kotlin это правило реализуется через идею: корутины запускаются внутри CoroutineScope, который определяет их жизненный цикл, а связь задач образует иерархию «родитель → дети».

Для начала зафиксируем, что сегодня мы не обсуждаем переключение потоков и Dispatchers, а также не уходим в темы отмены и обработки ошибок. Наша цель — научиться упаковывать корутины в управляемые границы и уметь дождаться их завершения.

2. CoroutineScope: «контейнер», в котором живут корутины

Если сказать совсем по‑человечески, CoroutineScope — это область, внутри которой вы имеете право запускать корутины, и которая задаёт им общий жизненный цикл. Технически launch() и async() — это extension‑функции именно на CoroutineScope, то есть без scope их «вежливо не получится запустить».

С точки зрения начинающего разработчика полезно держать в голове простую формулу:

«Scope = место, где корутины запускаются и где за них отвечают.»

runBlocking { ... } уже создаёт scope

В консольных приложениях мы часто начинаем корутинный мир с runBlocking. И важно понять: runBlocking не просто «даёт вызвать delay», он ещё и создаёт scope и ждёт завершения всего, что запущено внутри него (в пределах этого блока). Это фундаментальная часть «структурированности».

Мини‑пример, чтобы ощутить это поведение:


import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking

fun main() = runBlocking {
    launch {
        delay(100)
        println("worker done") // worker done
    }
    println("main in runBlocking") // main in runBlocking
}

Здесь программа не завершится раньше времени: runBlocking не «отпустит» main, пока не завершатся дочерние корутины, запущенные внутри блока.

Небольшая схема: scope как граница

Чтобы не превращать эту идею в туман, вот простая блок‑схема:

flowchart TD
    A["main()"] --> B["runBlocking { ... } создаёт scope"]
    B --> C["launch { ... } корутина-ребёнок"]
    B --> D["launch { ... } ещё ребёнок"]
    C --> E["завершилась"]
    D --> F["завершилась"]
    E --> G["runBlocking может завершиться"]
    F --> G

Ключевая мысль: scope — это не «абстракция ради абстракции», а граница, на которой можно честно сказать: работа закончена.

3. Job: «ручка управления» жизненным циклом задачи

Когда вы вызываете launch { ... }, Kotlin возвращает вам объект типа Job. И вот тут у новичков часто происходит путаница: кажется, что раз это «то, что вернул launch», значит, там должен быть результат. Но нет: launch — это «корутина для действия», и Job — это не результат, а описание жизненного цикла.

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

Самое практичное действие с Job: join()

Job.join() означает: «подожди, пока задача завершится».

Это не «магия синхронизации», а буквально очень честная команда: я хочу продолжить выполнение только после завершения этой корутины.

import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking

fun main() = runBlocking {
    val job: Job = launch {
        delay(100)
        println("task finished") // task finished
    }

    job.join()
    println("after join") // after join
}

Обратите внимание на полезную привычку: если по логике вам важно дождаться корутины, делайте это явно. Да, иногда можно положиться на то, что runBlocking и так дождётся всех детей, но join() делает зависимость видимой в коде, а читаемость — это недооценённая суперсила.

Job vs Deferred: не путать «жизнь» и «результат»

Здесь мы аккуратно закрепим отличие (оно было в прошлом дне, но сегодня это критично для ясности).

  • Job — это «задача существует / завершилась».
  • Deferred<T> — это «задача существует / завершилась и ещё принесла результат типа T».

Можно держать шпаргалку в виде таблицы:

Что вы запускаете Чем запускаете Что получаете Чем ждёте
Действие без результата
launch
Job
join()
Вычисление с результатом
async
Deferred<T>
await()

Важно: Deferred<T> тоже является задачей, но нас сегодня интересует именно логика «кто за кого отвечает» и «где граница завершения», поэтому фокус на Job и join().

4. Иерархия задач: родитель и дети

Одна из самых сильных идей structured concurrency в Kotlin звучит так: вложенные корутины образуют иерархию. Если вы запускаете launch внутри другой корутины, это становится «ребёнком» той корутины, внутри которой его создали. Контекст (в том числе Job) по умолчанию наследуется от родителя, и так строится дерево выполнения.

Это приятно, потому что структура кода начинает отражать структуру жизни задач: «эта подзадача — часть вот этой операции».

Пример: родитель ждёт ребёнка автоматически

Посмотрим на поведение без явного join() у ребёнка:

import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking

fun main() = runBlocking {
    val parent = launch {
        launch {
            delay(80)
            println("child done") // child done
        }

        delay(20)
        println("parent work") // parent work
    }

    parent.join()
    println("all finished") // all finished
}

Что здесь важно почувствовать: мы делаем parent.join(), а не child.join(). Но parent.join() не завершится, пока не завершится и дочерняя корутина. То есть родитель отвечает за детей.

«Дерево задач» как картинка

flowchart TD
    P["parent Job"] --> C1["child Job #1"]
    P --> C2["child Job #2"]

Если вы читаете код и видите «launch внутри launch», вы должны автоматически думать: «ага, это часть операции родителя». Это и есть практическое мышление structured concurrency.

5. coroutineScope { ... }: scope внутри suspend‑функции

Очень частая проблема новичка: «Я хочу внутри suspend fun запустить несколько корутин и дождаться их завершения. Мне что, снова runBlocking писать?» Нет, и более того — писать runBlocking внутри suspend почти всегда плохая идея, потому что вы начинаете блокировать поток там, где можно работать асинхронно.

Для таких случаев существует coroutineScope { ... }. Это builder, который создаёт новый scope внутри suspend‑функции и завершится только когда завершатся все корутины, запущенные внутри него. В примерах Kotlin это подчёркивается явно: выполнение продолжается после coroutineScope, когда завершились все корутины внутри.

Мини‑пример, который можно буквально запомнить как шаблон:

import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch

suspend fun doWorkTogether() = coroutineScope {
    launch { delay(30); println("A") } // A
    launch { delay(10); println("B") } // B
}

А теперь — вызов из main:

import kotlinx.coroutines.runBlocking

fun main() = runBlocking {
    doWorkTogether()
    println("after doWorkTogether") // after doWorkTogether
}

Ключевой смысл coroutineScope: это «структурированный контейнер» для параллельной работы внутри suspend‑функции. Он помогает не разносить launch по всему коду, а держать параллельность в одном месте — там, где вы явно задумали «сейчас будут несколько связанных задач».

6. Expense Tracker: один отчёт из параллельных шагов

Чтобы примеры не жили отдельной жизнью «в вакууме», продолжим практическую идею консольного приложения. Пусть у нас есть условный трекер расходов, который умеет собирать расходы в памяти и строить отчёты. Мы не лезем в файлы и сеть (это другие дни курса), поэтому «долгие операции» будем имитировать через delay(...).

Сегодняшняя цель внутри приложения — научиться строить отчёт так, чтобы он состоял из нескольких независимых частей, но оставался одной операцией с понятной границей завершения.

Заготовка модели

Сделаем простую модель расхода и тестовые данные. Это небольшая база, чтобы было что «анализировать»:

data class Expense(val category: String, val amount: Int)

fun demoExpenses(): List<Expense> = listOf(
    Expense("food", 1200),
    Expense("transport", 300),
    Expense("food", 450),
)

Подзадачи отчёта как suspend‑функции

Пусть отчёт состоит из двух частей: общая сумма и количество записей. Каждая часть «долгая» (условно), поэтому мы используем delay.

import kotlinx.coroutines.delay

suspend fun calcTotal(expenses: List<Expense>): Int {
    delay(80)
    return expenses.sumOf { it.amount }
}

suspend fun calcCount(expenses: List<Expense>): Int {
    delay(50)
    return expenses.size
}

Сейчас внимание: мы не делаем их параллельно. Мы лишь сделали две отдельные операции, которые можно будет объединить в один структурированный scope.

Собираем одну операцию из нескольких корутин внутри coroutineScope

Вот ключевой момент лекции: мы хотим запустить две части отчёта параллельно, но так, чтобы функция «построить отчёт» не возвращалась раньше времени. Это прямой случай для coroutineScope {}.

Сделаем версию, где мы используем launch и записываем результаты в var. Да, это мутируемое состояние — но в нашем учебном примере оно безопасно, потому что мы дождёмся завершения обеих корутин и читаем переменные только после этого. (Про более строгие подходы к общему состоянию поговорим в другие дни, сегодня нам важна структура жизни задач.)

import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.launch

suspend fun buildShortReport(expenses: List<Expense>): String = coroutineScope {
    var total = 0
    var count = 0

    val totalJob = launch { total = calcTotal(expenses) }
    val countJob = launch { count = calcCount(expenses) }

    totalJob.join()
    countJob.join()

    "count=$count, total=$total"
}

Здесь важны сразу две идеи:

Во-первых, coroutineScope гарантирует, что мы не «выйдем наружу», пока дети не завершились. Это structured concurrency в действии, и именно так Kotlin предлагает строить параллельные шаги как одну операцию.

Во-вторых, мы видим Job «вживую»: launch вернул Job, а join() стал явной точкой ожидания.

Подключаем к main() и смотрим результат

import kotlinx.coroutines.runBlocking

fun main() = runBlocking {
    val expenses = demoExpenses()
    val report = buildShortReport(expenses)
    println(report) // count=3, total=1950
}

Мы получили маленький, но важный навык: строить «операцию с внутренними параллельными шагами» без утечки корутин наружу.

Когда join() действительно нужен

Сейчас будет момент честности: иногда join() внутри coroutineScope кажется лишним, потому что «ну coroutineScope же и так ждёт детей». И это правда: сам по себе coroutineScope завершится, когда завершатся все корутины внутри.

Но join() остаётся полезным, когда вам нужна точная точка синхронизации в середине операции. Например, вы хотите дождаться шага A, потом сделать шаг B, при этом у вас параллельно крутится шаг C.

Покажем пример в контексте нашего отчёта: допустим, мы хотим сначала узнать total, а потом сформировать строку «проверка бюджета», и параллельно считать количество.

import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.launch

suspend fun buildBudgetReport(expenses: List<Expense>): String = coroutineScope {
    var total = 0
    var count = 0

    val totalJob = launch { total = calcTotal(expenses) }
    val countJob = launch { count = calcCount(expenses) }

    totalJob.join()
    val budgetLine = if (total > 1500) "budget: high" else "budget: ok"

    countJob.join()
    "count=$count, total=$total, $budgetLine"
}

Здесь join() — это не «костыль», а часть логики: нам нужен total, чтобы выбрать текст. И мы явно ставим барьер именно там, где он нужен.

7. Типичные ошибки

Ошибка №1: запуск корутин «в воздух» без понятного CoroutineScope.
Иногда новичок думает, что корутина — это просто «функция, которая работает сама». Тогда в коде появляются запуски, которые не привязаны к операции: непонятно, кто их ждёт и кто отвечает за завершение. Правильная привычка — запускать корутины только внутри scope, который является границей операции: в консоли это часто runBlocking, а внутри suspend‑кода — coroutineScope. Сама идея, что launch и async запускаются как функции расширения CoroutineScope, здесь не случайна: это языковой намёк «не запускай задачу без хозяина».

Ошибка №2: путаница “Job = результат”.
Очень распространённый сценарий: студент пишет val x = launch { ... } и потом пытается использовать x как вычисленное значение. Но launch возвращает Job, а Job — это «жизненный цикл», а не данные. Если вам нужен результат, вы выбираете async и await(), а Job используете для ожидания завершения через join().

Ошибка №3: ожидание “волшебной последовательности” без join().
Параллельность коварна тем, что вывод в консоль и порядок действий становятся непредсказуемыми, если вы не поставили точки ожидания. Если по логике вам нужно «сначала дождаться, потом продолжить», это должно быть выражено кодом. Самый простой и честный инструмент сегодня — job.join(). И даже если в конкретном месте можно положиться на то, что внешний scope дождётся всех задач, явный join() часто делает программу понятнее.

Ошибка №4: попытка использовать runBlocking внутри suspend‑функции.
Такое решение иногда выглядит как «ну мне же надо подождать». Но runBlocking блокирует поток и встраивает “синхронную границу” туда, где корутины должны оставаться асинхронными. Для структурированной конкуррентности внутри suspend‑кода предназначен coroutineScope, который создаёт управляемую границу и завершается только после завершения всех дочерних корутин.

1
Задача
Kotlin SELF, 53 уровень, 0 лекция
Недоступна
Сигнал завершения
Сигнал завершения
1
Задача
Kotlin SELF, 53 уровень, 0 лекция
Недоступна
Два результата
Два результата
1
Задача
Kotlin SELF, 53 уровень, 0 лекция
Недоступна
Команда и стажёр
Команда и стажёр
1
Задача
Kotlin SELF, 53 уровень, 0 лекция
Недоступна
Сбор слогана
Сбор слогана
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ