JavaRush/Courses/Kotlin SELF/Coroutine cancellation: cancel/join, cooperation, and isA...

Coroutine cancellation: cancel/join, cooperation, and isActive

Open

1. Introduction

If you’ve ever closed a browser tab where “everything froze”, then intuitively you already understand why cancellation is needed: sometimes work must finish earlier because the user changed their mind, input data changed, or simply “this operation is no longer needed”.

In Kotlin coroutines, cancellation is implemented gently: it is a request to stop work, not a “hammer blow to a thread’s head”. This is an important idea, because it affects how you write loops, where you place delay, and why sometimes after cancel() something still manages to get printed.

In Kotlin, most coroutine capabilities come from the kotlinx.coroutines library, which provides tools to start tasks, structure them, and manage their lifecycle.

Lifecycle: what exactly gets “cancelled”?

When you do launch { ... }, you get a Job. This is not a computation result, but an object that tracks the state of the running coroutine and allows you to control it.

It’s important that Job is part of the coroutine context, and the context is inherited from parent to child—this is exactly how a task hierarchy appears, where a parent can affect child coroutines.

Cancellation is a state change of a Job: we tell the task “please stop”. But it will stop not instantly, but when it reaches a “point where it can stop”, or when you explicitly provide for that in your code.

2. cancel(), join(), cancelAndJoin(): three verbs people often confuse

At this stage, many beginners get the feeling that coroutines have too many words for the simple action “stop and wait”. In reality, the words are different because the actions are different: one requests stopping, another waits for completion, and the third does both steps.

Table: what each call does

To avoid memorizing it “by feel”, let’s put it into a table:

Call What it does What it does not do When you need it
job.cancel()
Requests cancellation Does not wait for completion When you “ask it to stop”, but you don’t have to wait right here
job.join()
Waits for the coroutine to complete Does not cancel When you need to wait until the work is done (success/error/cancel—it doesn’t matter)
job.cancelAndJoin()
Requests cancellation and waits for completion — The typical “stop and guarantee it is definitely finished” scenario

Until you separate “request it to stop” from “wait until it has stopped” in your head, you’ll regularly run into weird effects like: “I cancelled it, but it’s still printing… why?”.

Mini example: cancel() without join()—and why this is not a bug

Here we’ll intentionally make an example where the coroutine manages to live a tiny bit after cancel(). This is not mockery—it’s a demonstration: cancellation is cooperative (more on that in the next section).

import kotlinx.coroutines.*

fun main() = runBlocking {
    val job = launch {
        repeat(5) { i ->
            delay(50)
            println("working $i")     // working 0 ... (depends on timing)
        }
    }

    delay(80)
    job.cancel()                      // request stop
    println("cancel requested")       // cancel requested

    delay(200)                        // do NOT wait for job, just wait in main
    println("main finished")          // main finished
}

Here you will almost certainly see "working 0" get printed, sometimes even "working 1", and only then it will “shut up”. And that’s fine: you said “stop”, but you didn’t say “and wait until you stop”.

Mini example: the correct “I want to definitely stop it”—cancelAndJoin()

Now the same idea, but “like an adult”: we requested cancellation and waited for completion.

import kotlinx.coroutines.*

fun main() = runBlocking {
    val job = launch {
        repeat(10) { i ->
            delay(50)
            println("step $i")        // step 0, step 1, ...
        }
    }

    delay(130)
    job.cancelAndJoin()               // cancel + wait
    println("stopped")                // stopped
}

The key feeling you need to catch: after cancelAndJoin() you can behave as if the coroutine definitely does not exist. Not “probably not”, not “seems like it’s gone”, but “it is guaranteed to be gone”.

3. Where a coroutine can actually stop

When people say “cooperative cancellation”, it sounds like something from corporate culture. In practice, the meaning is simple: Kotlin does not stop execution by force. It sets a cancellation flag in the Job, and then the coroutine either checks it itself, or reaches a point where the library checks it for you.

“Natural cancellation check” points

The most typical places where a coroutine “notices” it has been cancelled:

  • delay(...) (we already love and trust it)
  • other suspend functions (in general)
  • waiting for something via coroutine mechanisms

We won’t dive into the weeds now, but we’ll lock in one important idea: if inside your code there are no suspensions at all (suspend points), then the coroutine may “not notice” cancellation for a very long time. And that’s not “bad Kotlin”—it’s a logical consequence of the fact that coroutines do not forcibly break a thread.

Diagram: what happens on cancellation

Let’s draw it as a small flowchart, without extra philosophy:

flowchart TD
    A["Someone calls job.cancel()"] --> B["Job is marked as 'cancelling'"]
    B --> C["The coroutine keeps running"]
    C --> D{"Reached a suspend point
or checked isActive?"} D -->|yes| E["The coroutine finishes"] D -->|no| C

The main point: there can be a time gap between cancel() and “actual completion”. Sometimes small, sometimes large—depending on your code.

4. isActive: how to make your loops “well-behaved” and cancellable

If your coroutine lives on delay, it’s usually easy for it to be cancelled: delay is a convenient stop where the coroutine checks cancellation automatically. But as soon as you write your own loop (especially a computational one), you can accidentally create a coroutine that “politely listens to cancellation, but pretends not to hear it”.

This is where isActive comes in: a simple way to ask from inside the coroutine: “am I still active? or have I already been cancelled?”.

isActive in a loop with pauses: readable and honest

In this example delay already makes cancellation possible, but isActive adds clarity: we explicitly say “I work while I’m active”.

import kotlinx.coroutines.*

fun main() = runBlocking {
    val job = launch {
        var tick = 0
        while (isActive) {
            delay(40)
            println("tick ${tick++}")     // tick 0, tick 1, ...
        }
        println("loop ended")             // loop ended (usually won't make it)
    }

    delay(120)
    job.cancelAndJoin()
    println("done")                       // done
}

Yes, "loop ended" might not be printed (it depends on where exactly the coroutine was cancelled), but the idea of a “while active” loop is very healthy.

isActive in a “heavy” loop without delay: where it really saves you

Now an important case: a loop that does many steps without suspending. In such code, cancellation can “arrive” but then wait in a parking lot for a long time, unable to find an entrance.

import kotlinx.coroutines.*

fun main() = runBlocking {
    val job = launch(Dispatchers.Default) {
        var sum = 0L
        for (i in 1..50_000_000) {
            if (!isActive) return@launch
            sum += i
            if (i % 10_000_000 == 0) println("checkpoint $i") // checkpoint 10000000 ...
        }
        println("sum = $sum")
    }

    delay(30)
    job.cancelAndJoin()
    println("cancelled")                  // cancelled
}

Here isActive is literally a “smoke detector”: if it’s not there, you can cancel the task, but it will stubbornly keep counting to the bitter end (or until the loop finishes), especially on fast machines.

5. Mini practical example: cancelling a “long report”

Now we’ll make a snippet you can mentally plug into a learning console application. The idea is realistic: the user starts a report, the report “runs” for a while, and then the user changes their mind and wants to cancel it.

The “build report” function: make it cancellable

We won’t read files and we won’t hit the network—we’ll just simulate long work via delay, to focus on cancellation.

import kotlinx.coroutines.delay

suspend fun buildReport(): String {
    repeat(5) { step ->
        delay(100) // simulate a long step
        println("report step ${step + 1}/5") // report step 1/5 ...
    }
    return "REPORT: OK"
}

This code is already “cancellable”, because delay is a suspend point where cancellation is usually noticed quickly.

Running the report in launch: get a Job and control it

Now let’s put control together: start the report as a task, wait a little bit, and cancel it—like the user pressed “stop”.

import kotlinx.coroutines.*

fun main() = runBlocking {
    val job: Job = launch {
        val result = buildReport()
        println(result)                   // if not cancelled, will be REPORT: OK
    }

    delay(230)
    println("user requested stop")        // user requested stop
    job.cancelAndJoin()
    println("report cancelled")           // report cancelled
}

Pay attention to the important “intonation” of the code: cancelAndJoin() sits right next to the place where it matters to us to guarantee the stop. If after cancellation you want, for example, to show a command menu or start another report, it’s better to do it after cancelAndJoin(), so you don’t end up with two “parallel reports” printing to the console at the same time.

Two Jobs: “work” + “progress indicator”, and cancelling both at once

Now something more life-like: while the report is being built, we show “progress dots” every 50 ms. That is done by a separate coroutine. And here cancellation becomes even more useful: you cancel the parent—and the child goes home too.

A quick reminder of the idea: the coroutine context forms a hierarchy where Job is connected by a parent–child relationship, which gives you structured concurrency (including shared cancellation of related tasks).

import kotlinx.coroutines.*

fun main() = runBlocking {
    val reportJob = launch {
        val spinnerJob = launch {
            while (isActive) {
                delay(50)
                print(".")                // .....
            }
        }

        val report = buildReport()
        spinnerJob.cancelAndJoin()
        println("\n$report")
    }

    delay(230)
    reportJob.cancelAndJoin()
    println("\nstopped all")
}

There are two nice effects here. First, the “dots” stop printing because we cancelled reportJob, and it is the parent of spinnerJob. Second, we still have an explicit spinnerJob.cancelAndJoin() as a neat way to stop the indicator, if the report managed to finish on its own.

How to think about cancellation: “request” + “guarantee”

Beginners’ problem is usually not that they don’t know the word cancelAndJoin(), but that they use cancel() like a “magic stop button” and expect an instant result.

A convenient mental model is: cancellation almost always consists of two parts—you request a stop, and then (if it matters) you guarantee that the stop has already happened by waiting for completion. Typically, the guarantee is join() or cancelAndJoin(). If you skip the second part, you are consciously agreeing to live in a world of “maybe it has already stopped, or maybe it’s still printing to the console”.

6. Typical mistakes when cancelling coroutines

Mistake No. 1: thinking that cancel() is an instant kill.
cancel() only marks the Job as cancelled/cancelling. If there are no suspension points inside the coroutine and no isActive checks, it may keep running for quite a while. Because of this, it can feel like “cancellation doesn’t work”, while in reality you simply didn’t give the coroutine a place where it could stop.

Mistake No. 2: cancelling a task and immediately moving on, forgetting to wait for completion.
A classic bug in console apps: you cancelled a coroutine, showed a menu, started a new operation—and suddenly the old coroutine is still printing messages. It looks like a “poltergeist in the terminal”. If it’s important that the task is definitely finished, use cancelAndJoin() (or cancel() + join()).

Mistake No. 3: writing long loops without delay and without isActive.
If you do computations in a large for or while, the coroutine may not check cancellation for a very long time. As a result, the “cancel” button turns into “schedule cancellation for tomorrow”. A simple if (!isActive) return@launch makes the behavior controllable.

Mistake No. 4: mixing up “logical completion” and “cancellation” as if they were the same thing.
Sometimes a developer cancels a Job, but later in the code assumes that the operation completed successfully and the result can be used. Cancellation is a separate completion scenario, and semantically it is closer to “operation interrupted”. Therefore, after cancellation you should design your code so it does not depend on the result of the cancelled task.

Mistake No. 5: cancelling “somewhere deep down” without returning control upward.
There can be a temptation to call cancel() from deep inside and keep pretending everything is under control. But cancellation is part of the lifecycle, and it needs a clear control point: who cancels, who waits for completion, and what happens next. If this is not structured, you end up with code that is hard to read and even harder to debug.

1
Task
Kotlin SELF,  53 level,  2 lecture
Locked
Cancellation without waiting
Cancellation without waiting
1
Task
Kotlin SELF,  53 level,  2 lecture
Locked
Guaranteed stop
Guaranteed stop
1
Task
Kotlin SELF,  53 level,  2 lecture
Locked
Heavy computation
Heavy computation
1
Task
Kotlin SELF,  53 level,  2 lecture
Locked
Report and spinner
Report and spinner
Комментарии
  • популярные
  • новые
  • старые
Для того, чтобы оставить комментарий Вы должны sign in
This page has no comments yet