JavaRush /Courses /Kotlin SELF /CoroutineScope and Job — task hierarchy and lifecycle man...

CoroutineScope and Job — task hierarchy and lifecycle management

Kotlin SELF
Level 53, Lesson 0
Available

1. Introduction

When you start writing coroutines, a thought quickly appears in your head: “Oh, awesome! I’ll parallelize everything now!” — and that’s a normal stage. But very soon a question pops up that sounds boring, yet saves your nerves: who is responsible for these tasks, and when are they supposed to finish?

Imagine your program is a kitchen. main() is you, and coroutines are assistant cooks. If you just shout, “Hey, someone chop the vegetables!” and leave to the store, when you come back you don’t know: are the vegetables already chopped, did the assistant get tired and leave, or are they still slicing one tomato alone and crying. Structured concurrency is the rule “cooks work within a shift”: if the shift (operation) is over, all assistants must finish their work, and the kitchen must end up in a clear, understandable state.

In Kotlin, this rule is implemented through the idea that coroutines are launched inside CoroutineScope, which defines their lifecycle, and the relationship between tasks forms a “parent → children” hierarchy.

To start with, let’s note that today we are not discussing thread switching and Dispatchers, and we are also not diving into cancellation and error handling. Our goal is to learn how to package coroutines into managed boundaries and be able to wait for them to complete.

2. CoroutineScope: the “container” where coroutines live

In plain terms, CoroutineScope is an area in which you are allowed to launch coroutines, and which gives them a shared lifecycle. Technically, launch() and async() are extension functions on CoroutineScope itself, meaning that without a scope you “can’t politely launch them.”

From a beginner’s perspective, it helps to keep a simple formula in mind:

“Scope = the place where coroutines are launched and where someone is responsible for them.”

runBlocking { ... } already creates a scope

In console applications, we often enter the coroutine world with runBlocking. And it’s important to understand: runBlocking doesn’t just “let you call delay” — it also creates a scope and waits for everything launched inside it to finish (within this block). This is a fundamental part of “structuredness.”

A mini example to feel this behavior:


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
}

Here the program won’t end too early: runBlocking won’t “release” main until the child coroutines launched inside the block are finished.

A small diagram: scope as a boundary

To avoid turning this idea into fog, here’s a simple flowchart:

flowchart TD
    A["main()"] --> B["runBlocking { ... } creates a scope"]
    B --> C["launch { ... } child coroutine"]
    B --> D["launch { ... } another child"]
    C --> E["completed"]
    D --> F["completed"]
    E --> G["runBlocking can complete"]
    F --> G

The key idea: a scope is not “abstraction for abstraction’s sake,” but a boundary where you can honestly say: the work is done.

3. Job: a “handle” for a task’s lifecycle

When you call launch { ... }, Kotlin returns an object of type Job. And this is where beginners often get confused: it feels like since it’s “what launch returned,” it must contain a result. But no: launch is a “coroutine for an action,” and Job is not a result — it is a description of the lifecycle.

The documentation explicitly describes Job as a context element that tracks a coroutine’s lifecycle and helps structure execution.

The most practical thing to do with Job: join()

Job.join() means: “wait until the task completes.”

This is not “synchronization magic,” but a very honest command: I want to continue only after this coroutine has finished.

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
}

Notice a useful habit: if, logically, you need to wait for a coroutine, do it explicitly. Yes, sometimes you can rely on the fact that runBlocking will wait for all children anyway, but join() makes the dependency visible in the code, and readability is an underrated superpower.

Job vs Deferred: don’t mix up “lifecycle” and “result”

Here we’ll carefully reinforce the difference (it was covered on a previous day, but today it’s critical for clarity).

  • Job — “the task exists / completed.”
  • Deferred<T> — “the task exists / completed and also produced a result of type T.”

You can keep a cheat sheet as a table:

What you start How you start it What you get How you wait
Action without a result
launch
Job
join()
Computation with a result
async
Deferred<T>
await()

Important: Deferred<T> is also a task, but today we’re interested specifically in the “who is responsible for whom” logic and “where is the completion boundary,” so the focus is on Job and join().

4. Task hierarchy: parent and children

One of the strongest ideas of structured concurrency in Kotlin is this: nested coroutines form a hierarchy. If you start launch inside another coroutine, it becomes a “child” of the coroutine in which it was created. The context (including Job) is inherited from the parent by default, and that’s how an execution tree is built.

This is nice because the structure of the code starts reflecting the structure of the tasks’ lifecycle: “this subtask is part of this operation.”

Example: the parent automatically waits for the child

Let’s look at the behavior without an explicit child 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
}

What matters here: we do parent.join(), not child.join(). But parent.join() won’t complete until the child coroutine completes as well. In other words, the parent is responsible for the children.

A “task tree” as a picture

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

If you read code and see “launch inside launch,” you should automatically think: “okay, this is part of the parent’s operation.” That is practical structured concurrency thinking.

5. coroutineScope { ... }: a scope inside a suspend function

A very common beginner problem: “I want to start several coroutines inside a suspend fun and wait for them to finish. Do I need to write runBlocking again?” No — and moreover, writing runBlocking inside suspend is almost always a bad idea, because you start blocking a thread where you could work asynchronously.

For these cases there is coroutineScope { ... }. It’s a builder that creates a new scope inside a suspend function and completes only when all coroutines launched inside it are complete. In Kotlin examples this is emphasized explicitly: execution continues after coroutineScope once all coroutines inside have finished.

A mini example you can literally memorize as a template:

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
}

And now — the call from main:

import kotlinx.coroutines.runBlocking

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

The key meaning of coroutineScope: it’s a “structured container” for parallel work inside a suspend function. It helps you avoid scattering launch all over the code, and instead keep parallelism in one place — where you explicitly intended “now there will be several related tasks.”

6. Expense Tracker: one report from parallel steps

So the examples don’t live a separate life “in a vacuum,” let’s continue the practical idea of a console application. Suppose we have a simple expense tracker that can collect expenses in memory and build reports. We’re not touching files or the network (those are other days of the course), so we’ll simulate “long operations” using delay(...).

Today’s goal inside the application is to learn how to build a report so that it consists of multiple independent parts, but still remains a single operation with a clear completion boundary.

Model stub

Let’s create a simple expense model and test data. This is a small base so we have something to “analyze”:

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

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

Report subtasks as suspend functions

Let the report have two parts: total sum and record count. Each part is “slow” (conditionally), so we use 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
}

Now pay attention: we are not running them in parallel yet. We only created two separate operations that we can later combine into one structured scope.

Combine multiple coroutines into a single operation inside coroutineScope

Here’s the key moment of the lecture: we want to run two parts of the report in parallel, but in such a way that the “build report” function does not return too early. This is a direct use case for coroutineScope {}.

Let’s make a version where we use launch and write results into var. Yes, it’s mutable state — but in our learning example it’s safe because we will wait for both coroutines to complete, and we only read the variables after that. (We’ll talk about stricter approaches to shared state on other days; today we care about task lifecycle structure.)

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"
}

Two ideas matter here at once:

First, coroutineScope guarantees that we won’t “exit outward” until the children have completed. This is structured concurrency in action, and this is exactly how Kotlin suggests building parallel steps as a single operation.

Second, we see Job “live”: launch returned a Job, and join() became an explicit wait point.

Hook it up to main() and look at the result

import kotlinx.coroutines.runBlocking

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

We gained a small but important skill: building an “operation with internal parallel steps” without leaking coroutines outside.

When join() is truly needed

Now for an honest moment: sometimes join() inside coroutineScope feels redundant because “coroutineScope already waits for children.” And that’s true: coroutineScope itself completes when all coroutines inside complete.

But join() remains useful when you need an exact synchronization point in the middle of an operation. For example, you want to wait for step A, then do step B, while step C is running in parallel.

Let’s show an example in the context of our report: suppose we want to learn total first, then build a “budget check” line, while counting is happening in parallel.

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"
}

Here join() is not a “hack,” but part of the logic: we need total to choose the text. And we explicitly place the barrier exactly where we need it.

7. Common mistakes

Mistake #1: launching coroutines “into the air” without a clear CoroutineScope.
Sometimes a beginner thinks a coroutine is just “a function that runs by itself.” Then the code gets launches that are not tied to an operation: it’s unclear who waits for them and who is responsible for completion. The right habit is to launch coroutines only inside a scope that is the boundary of an operation: in console apps this is often runBlocking, and inside suspend code it’s coroutineScope. The fact that launch and async are extension functions on CoroutineScope is not accidental: it’s a language hint — “don’t start a task without an owner.”

Mistake #2: confusing “Job = result.”
A very common scenario: a student writes val x = launch { ... } and then tries to use x as a computed value. But launch returns Job, and Job is a lifecycle, not data. If you need a result, you choose async and await(), and you use Job to wait for completion via join().

Mistake #3: expecting a “magic sequence” without join().
Parallelism is tricky because console output and action order become unpredictable if you don’t define wait points. If your logic requires “wait first, then continue,” that must be expressed in code. The simplest and most honest tool today is job.join(). And even if in a specific place you can rely on the outer scope waiting for all tasks, an explicit join() often makes the program clearer.

Mistake #4: trying to use runBlocking inside a suspend function.
This solution sometimes looks like “but I need to wait.” However, runBlocking blocks the thread and inserts a “synchronous boundary” where coroutines should remain asynchronous. For structured concurrency inside suspend code, use coroutineScope, which creates a managed boundary and completes only after all child coroutines complete.

1
Task
Kotlin SELF, level 53, lesson 0
Locked
Completion signal
Completion signal
1
Task
Kotlin SELF, level 53, lesson 0
Locked
Two results
Two results
1
Task
Kotlin SELF, level 53, lesson 0
Locked
Team and intern
Team and intern
1
Task
Kotlin SELF, level 53, lesson 0
Locked
Assembling the tagline
Assembling the tagline
Comments
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION