Cancellation

Basic cancellation

The Job interface has a cancel method, which allows its cancellation. Calling it triggers the following effects:

  • Cancelled coroutine ends the job at the first suspension point (see: Cancellation Cooperative Functions/suspension point).
  • If a job has some children, they are also cancelled (but its parent is not affected).
  • Once a job is cancelled, it cannot be used as a parent for any new coroutines. It is first in the “Cancelling” and then in the “Cancelled” state. (see Job states)

Basic example cancellation:

suspend fun main(): Unit = coroutineScope {
  val job = launch (CoroutineName("job")){
    repeat(1_000) { i ->
      delay(200)
      out.info("Printing $i")
    }
  }

  delay(1100)
  job.cancel()
  
  // JOIN is important, to make sure cancellation is complete. 
  job.join()

  out.info("Cancelled successfully")
}
GT-Sandbox-Snapshot

Code

package com.glassthought.sandbox

import gt.sandbox.util.output.Out
import kotlinx.coroutines.CoroutineName
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch

val out = Out.standard()

suspend fun main(): Unit = coroutineScope {
  val job = launch (CoroutineName("job")){
    repeat(1_000) { i ->
      delay(200)
      out.info("Printing $i")
    }
  }

  delay(1100)
  job.cancel()
  job.join()

  out.info("Cancelled successfully")
}

Command to reproduce:

gt.sandbox.checkout.commit aad4f965c9769347302e \
&& cd "${GT_SANDBOX_REPO}" \
&& cmd.run.announce "./gradlew run --quiet"

Recorded output of command:

[elapsed:  268ms][tname:DefaultDispatcher-worker-1/tid:20][coroutine:job] Printing 0
[elapsed:  487ms][tname:DefaultDispatcher-worker-1/tid:20][coroutine:job] Printing 1
[elapsed:  692ms][tname:DefaultDispatcher-worker-1/tid:20][coroutine:job] Printing 2
[elapsed:  896ms][tname:DefaultDispatcher-worker-1/tid:20][coroutine:job] Printing 3
[elapsed: 1101ms][tname:DefaultDispatcher-worker-1/tid:20][coroutine:job] Printing 4
[elapsed: 1147ms][tname:DefaultDispatcher-worker-1/tid:20][coroutine:unnamed] Cancelled successfully

Default Cancellation Behavior

In ALL scopes that use Regular Job:

Highlight

Job - provide another avenue of stopping work other than traditional exceptions. Separate cancellation mechanism that is not as apparent as typical exception flow, where failed co-routine cancel's all co-routines that are related to it through Job hierarchy.

  • Each coroutine has its own Job instance.
  • Each Job instance is tied to it's parent job (if it has a parent Job). This tie in enables cancellation behavior.

Example

If regular Job() were used. If Coroutine#6 throws non-CancellationException exception the entire hierarchy is gonig to be cancelled and Coroutine#1 will rethrow the exception that Coroutine#6 threw.

img

Creating Error Boundary

Gotchas

Cooperative

Cancellation is cooperative: co-routines MUST call Cancellation Cooperative Functions/suspension point to receive CancellationException and when they do receive Cancellation Exception they must rethrow it.

Functions that check for coroutine cancellation at suspension points and throw CancellationException when co-routine is cancelled.

Examples:

Behavior: Stop execution immediately when parent scope is cancelled, enabling structured concurrency and proper cleanup.

Relationships

For Searchability

For Searchability
  • Cancellation Aware Functions
  • Respect CancellationException
  • Suspending functions that respect cancellation
  • Cooperative cancellation
  • Cancellation propagation
  • Cancellation point
  • suspension point that checks for cancellation

Gotchas

Cancellation is cooperative co-routines MUST call Cancellation Cooperative Functions/suspension point to receive CancellationException and when they do receive Cancellation Exception they must rethrow it.

Example we explicitly swallow Cancellation

runBlocking - cancelled co-routine immorally "swallows" Cancellation exception, and keeps going - 😈🐛😈

Dont do this!

This is clear example of what you should NOT be doing.

In this example we have 2 co-routines:

  • First co-routine is going to throw exception after 2 seconds.
  • Second co-routine is processing some unrelated messages every half second.

Once 1st co-routine throws exception:

  • Second co-routine gets Cancellation Exception on invocation of delay() (delay is Cancellation Cooperative Functions/suspension point)
    • BUT in this case second co-routine does something 😈evil😈 and it logs Cancellation exception WITHOUT rethrow, and keeps going. Allowing second co-routine to execute its functionality back-to-back WITHOUT delay.
  • After second co-routine immorally finishes all of its messages without delay, the original exception is rethrown to the parent. Where it's caught in the catch block at main() level.

Code

package com.glassthought.sandbox

import gt.sandbox.util.output.Out
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineName
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking

suspend fun main(): kotlin.Unit {
  val out = Out.standard()

  out.info("START")

  try {
    val runBlocking = runBlocking {
      launch(CoroutineName("WillFail")) {
        val timeMillis = 2000L
        out.info("This one will throw in $timeMillis ms")
        delay(timeMillis)
        out.warn("OK I am about to throw an exception!")
        throw MyExceptionWillThrowFromCoroutine("I-Failed-In-CoRoutine")
      }

      launch(CoroutineName("JustPrints")) {
        (0..10)
          .map { "a-${it}" }
          .forEach {
            out.info(it)

            try {
              delay(500)
            } catch (e: CancellationException) {
              val excMsg = e.message ?: e.toString()
              val emoji = "\uD83D\uDE08"
              out.warn("$emoji I caught and swallowed [${e::class.simpleName}/$excMsg] thrown from delay() $emoji")
            }
          }
      }
    }
  } catch (e: Exception) {
    out.error("runBlocking threw an exception! of type=[${e::class.simpleName}] with msg=[${e.message}]")
  }


  out.info("DONE")
}

class MyExceptionWillThrowFromCoroutine(msg: String) : RuntimeException(msg)

Command to reproduce:

gt.sandbox.checkout.commit 7b37b9e9e1061e7d56c7 \
&& cd "${GT_SANDBOX_REPO}" \
&& cmd.run.announce "./gradlew run --quiet"

Recorded output of command:

Picked up JAVA_TOOL_OPTIONS: -Dkotlinx.coroutines.debug
Picked up JAVA_TOOL_OPTIONS: -Dkotlinx.coroutines.debug
[INFO][elapsed:   17ms][🥇][🧵][tname:main/tid:1] START
[INFO][elapsed:   47ms][🥇][1️⃣][coroutname:@WillFail#2][tname:main/tid:1] This one will throw in 2000 ms
[INFO][elapsed:   54ms][🥇][2️⃣][coroutname:@JustPrints#3][tname:main/tid:1] a-0
[INFO][elapsed:  555ms][🥇][2️⃣][coroutname:@JustPrints#3][tname:main/tid:1] a-1
[INFO][elapsed: 1056ms][🥇][2️⃣][coroutname:@JustPrints#3][tname:main/tid:1] a-2
[INFO][elapsed: 1556ms][🥇][2️⃣][coroutname:@JustPrints#3][tname:main/tid:1] a-3
[WARN][elapsed: 2052ms][🥇][1️⃣][coroutname:@WillFail#2][tname:main/tid:1] OK I am about to throw an exception!
[WARN][elapsed: 2101ms][🥇][2️⃣][coroutname:@JustPrints#3][tname:main/tid:1] 😈 I caught and swallowed [JobCancellationException/Parent job is Cancelling] thrown from delay() 😈
[INFO][elapsed: 2101ms][🥇][2️⃣][coroutname:@JustPrints#3][tname:main/tid:1] a-4
[WARN][elapsed: 2101ms][🥇][2️⃣][coroutname:@JustPrints#3][tname:main/tid:1] 😈 I caught and swallowed [JobCancellationException/Parent job is Cancelling] thrown from delay() 😈
[INFO][elapsed: 2102ms][🥇][2️⃣][coroutname:@JustPrints#3][tname:main/tid:1] a-5
[WARN][elapsed: 2102ms][🥇][2️⃣][coroutname:@JustPrints#3][tname:main/tid:1] 😈 I caught and swallowed [JobCancellationException/Parent job is Cancelling] thrown from delay() 😈
[INFO][elapsed: 2102ms][🥇][2️⃣][coroutname:@JustPrints#3][tname:main/tid:1] a-6
[WARN][elapsed: 2102ms][🥇][2️⃣][coroutname:@JustPrints#3][tname:main/tid:1] 😈 I caught and swallowed [JobCancellationException/Parent job is Cancelling] thrown from delay() 😈
[INFO][elapsed: 2102ms][🥇][2️⃣][coroutname:@JustPrints#3][tname:main/tid:1] a-7
[WARN][elapsed: 2102ms][🥇][2️⃣][coroutname:@JustPrints#3][tname:main/tid:1] 😈 I caught and swallowed [JobCancellationException/Parent job is Cancelling] thrown from delay() 😈
[INFO][elapsed: 2102ms][🥇][2️⃣][coroutname:@JustPrints#3][tname:main/tid:1] a-8
[WARN][elapsed: 2102ms][🥇][2️⃣][coroutname:@JustPrints#3][tname:main/tid:1] 😈 I caught and swallowed [JobCancellationException/Parent job is Cancelling] thrown from delay() 😈
[INFO][elapsed: 2103ms][🥇][2️⃣][coroutname:@JustPrints#3][tname:main/tid:1] a-9
[WARN][elapsed: 2103ms][🥇][2️⃣][coroutname:@JustPrints#3][tname:main/tid:1] 😈 I caught and swallowed [JobCancellationException/Parent job is Cancelling] thrown from delay() 😈
[INFO][elapsed: 2103ms][🥇][2️⃣][coroutname:@JustPrints#3][tname:main/tid:1] a-10
[WARN][elapsed: 2103ms][🥇][2️⃣][coroutname:@JustPrints#3][tname:main/tid:1] 😈 I caught and swallowed [JobCancellationException/Parent job is Cancelling] thrown from delay() 😈
[ERROR][elapsed: 2104ms][🥇][🧵][tname:main/tid:1] runBlocking threw an exception! of type=[MyExceptionWillThrowFromCoroutine] with msg=[I-Failed-In-CoRoutine]
[INFO][elapsed: 2105ms][🥇][🧵][tname:main/tid:1] DONE

Can inadvertently swallow CancellationException

While the above example quite clearly does something evil explicitly swallowing the CancellationException, we can very easily run into this issue by inadvertently swallowing cancellation exception. Let's take a look at example pseudo-code to illustrate.

Example inadvertently swallowing CancellationException

// In this example you parse some lines and it's ok for some lines to fail parsing.
// So you try{}catch()/log and keep going.
// 
// But you inadvertently end up swallowing cancellation exception and hence not respecting cancellation.
parseLines(){
    for (lines):{
        try {
            accumulator.add(parseVisitHistoryLine())
        } catch(e: Exception){
            // Let's say its ok for some lines to not be parsable so you log and keep going.
            // The problem though is that CancellationException is descendent of Exception 
            // So what you are not respecting cancellation here.
    
            log.dataError(e)
        }
    }

    return accumulator
}

Must Rethrow CancellationException

// What you should do in this case is to have a separate catch for CancellationException and rethrow it.
parseLines(){
    try{
        accumulator.add(parseVisitHistoryLine())
     } catch(cancelExc: CancellationException){
        // Log cancellation with some context
        log.cancel(cancelExc)

        // Re-throw cancellation exception so that you cooperate with cancellation.
        throw cancelExc
    } catch(e: Exception){
        // Log other exceptions and keep going.
        log.dataError(e)
    }

    return accumulator
}


Children
  1. CancellationException
  2. Cooperative Cancellation