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».
Можно держать шпаргалку в виде таблицы:
| Что вы запускаете | Чем запускаете | Что получаете | Чем ждёте |
|---|---|---|---|
| Действие без результата | |
|
|
| Вычисление с результатом | |
|
|
Важно: 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, который создаёт управляемую границу и завершается только после завершения всех дочерних корутин.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ