JavaRush /Курси /Kotlin SELF /CoroutineScope і Job — ієрархія завдань і керування життє...

CoroutineScope і Job — ієрархія завдань і керування життєвим циклом

Kotlin SELF
Рівень 53 , Лекція 0
Відкрита

1. Вступ

Коли ви починаєте писати корутини, у голові швидко зʼявляється думка: «О, круто! Зараз я все розпаралелю!» — і це нормальна стадія. Але дуже скоро спливає питання, яке звучить нудно, зате рятує нерви: а хто відповідає за ці завдання і коли вони мають завершитися?

Уявіть, що ваша програма — це кухня. main() — це ви, а корутини — кухарі-помічники. Якщо ви просто крикнули: «Гей, хтось наріжте овочі!» — і пішли в магазин, то, повернувшись, ви вже не знаєте: овочі нарізані, помічник стомився й пішов чи він досі ріже помідор наодинці й плаче. Структурована конкурентність — це правило «кухарі працюють у межах зміни»: якщо зміна (операція) закінчилася, усі помічники мають завершити свою роботу, а кухня — перейти у зрозумілий стан.

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

Для початку зафіксуймо: сьогодні ми не обговорюємо перемикання потоків і Dispatchers, а також не заглиблюємося в теми скасування та оброблення помилок. Наша мета — навчитися «пакувати» корутини в керовані межі та вміти дочекатися їхнього завершення.

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

Якщо сказати зовсім по‑людськи, CoroutineScope — це область, у межах якої ви можете запускати корутини, і яка задає їм спільний життєвий цикл. Технічно launch() і async() — це функції розширення саме для 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
    }
    println("main у 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
    }

    job.join()
    println("після join") // after join
}

Зверніть увагу на корисну звичку: якщо за логікою вам важливо дочекатися корутини, робіть це явно. Так, інколи можна покластися на те, що runBlocking і так дочекається всіх дітей, але join() робить залежність видимою в коді, а читабельність — це недооцінена суперсила.

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

Тут ми акуратно зафіксуємо відмінність (вона була в попередньому дні, але сьогодні це критично для ясності).

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

Можна тримати шпаргалку у вигляді таблиці:

Що ви запускаєте Чим запускаєте Що отримуєте Чим чекаєте
Дія без результату
launch
Job
join()
Обчислення з результатом
async
Deferred<T>
await()

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

4. Ієрархія завдань: батько і діти

Одна з найсильніших ідей структурованої конкурентності в 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
        }

        delay(20)
        println("робота батька") // parent work
    }

    parent.join()
    println("усе завершено") // all finished
}

Що тут важливо відчути: ми робимо parent.join(), а не child.join(). Але parent.join() не завершиться, доки не завершиться й дочірня корутина. Тобто батько відповідає за дітей.

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

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

Якщо ви читаєте код і бачите «launch усередині launch», варто автоматично думати: «ага, це частина операції батька». Це і є практичне мислення структурованої конкурентності.

5. coroutineScope { ... }: scope усередині suspend‑функції

Дуже часта проблема новачків: «Я хочу всередині suspend fun запустити кілька корутин і дочекатися їхнього завершення. Мені що, знову runBlocking писати?» Ні. І більше того, писати runBlocking усередині suspend майже завжди погана ідея, тому що ви починаєте блокувати потік там, де можна працювати асинхронно.

Для таких випадків існує coroutineScope { ... }. Це побудовник, який створює новий 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("після 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 гарантує, що ми не «вийдемо назовні», доки діти не завершилися. Це структурована конкурентність у дії, і саме так 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) "бюджет: високий" else "бюджет: ок"

    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, який створює керовану межу й завершується лише після завершення всіх дочірніх корутин.

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ