CoroutineScope

In simple terms, a CoroutineScope in Kotlin is like a "container" that manages your coroutines. It defines the boundaries and the "lifetime" for the coroutines you start within it, ensuring that they are automatically canceled when they are no longer needed.

Job is the primary mechanism for controlling cancellation behavior of co-routines.

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

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

too many nested note refs

img

In the “Active” state, a job is running and doing its job. If the job is created with a coroutine builder, this is the state where the body of this coroutine will be executed. In this state, we can start child coroutines. Most coroutines will start in the “Active” state. Only those that are started lazily will start with the “New” state. These need to be started in order for them to move to the “Active” state. When a coroutine is executing its body, it is surely in the “Active” state. When it is done, its state changes to “Completing”, where it waits for its children. Once all its children are done, the job changes its state to “Completed”, which is a terminal one. Alternatively, if a job cancels or fails when running (in the “Active” or “Completing” state), its state will change to “Cancelling”. In this state, we have the last chance to do some clean-up, like closing connections or freeing resources (we will see how to do this in the next chapter). Once this is done, the job will move to the “Cancelled” state.

StateisActiveisCompletedisCancelled
New (optional initial state)falsefalsefalse
Active (default initial state)truefalsefalse
Completing (transient state)truefalsefalse
Cancelling (transient state)falsefalsetrue
Cancelled (final state)falsetruetrue
Completed (final state)falsetruefalse

SupervisorJob is similar to a regular Job with the difference that cancellation is propagated only downwards, which means:

  • Children can fail independently of each other.
  • Does not auto-fail parent.

A failure or cancellation of a child does not cause the supervisor job to fail and does not affect its other children.

Straightforward Examples

too many nested note refs

Trickier examples

too many nested note refs

What is a "Scope with Regular Job"?

A Scope with Regular Job in Kotlin coroutines refers to any CoroutineScope that uses Job() and hence abides by default exception handling behavior - where uncaught non-cancellation exceptions in child coroutine Cancels (shutsdown) its entire hierarchy of co-routines.

Default Exception 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

Code example

too many nested note refs

too many nested note refs

too many nested note refs

too many nested note refs

too many nested note refs

Also see

too many nested note refs

Scope with Regular Job - throw CancellationException - stops co-routine that threw. Does NOT stop sibling co-routine, does not rethrow to parent.

Code

package com.glassthought.sandbox

import gt.sandbox.util.output.Emojis
import gt.sandbox.util.output.Out
import kotlinx.coroutines.*
import kotlin.system.exitProcess

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

  try {
    runBlocking {

      launch(CoroutineName("WillThrowCancelExc")) {
        // Loop over a range from 1 to 5 (inclusive)
        val howMany = 5
        for (i in 1..howMany) {
          val timeMillis = 1000L
          out.info("I will call throw CancellationException in $timeMillis ms - processing value:[${i}/${howMany}]")
          delay(timeMillis)
          out.warn("I am throwing CancellationException at value - [${i}/${howMany}]")

          throw CancellationException("cancel-message")
        }
      }

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

            try {
              delay(500)
            } catch (e: CancellationException) {
              val excMsg = e.message ?: e.toString()
              out.warn("${Emojis.OBIDIENT} I have caught [${e::class.simpleName}/$excMsg], and rethrowing it ${Emojis.OBIDIENT} ")

              throw e
            }
          }

        out.info("${Emojis.CHECK_MARK} I have FINISHED all of my messages.")
      }
    }

  } catch (e: Exception) {
    out.error("runBlocking threw an exception! of type=[${e::class.simpleName}] with msg=[${e.message}]")

    exitProcess(1)
  }

  out.info("DONE no errors at main.")
}

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

Command to reproduce:

gt.sandbox.checkout.commit b7c3be031f6453aa7ce9 \
&& 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:   25ms][🥇][🧵][tname:main/tid:1] START
[INFO][elapsed:   67ms][🥇][①][coroutname:@WillThrowCancelExc#2][tname:main/tid:1] I will call throw CancellationException in 1000 ms - processing value:[1/5]
[INFO][elapsed:   74ms][🥇][⓶][coroutname:@JustPrints#3][tname:main/tid:1] a-0
[INFO][elapsed:  575ms][🥇][⓶][coroutname:@JustPrints#3][tname:main/tid:1] a-1
[WARN][elapsed: 1074ms][🥇][①][coroutname:@WillThrowCancelExc#2][tname:main/tid:1] I am throwing CancellationException at value - [1/5]
[INFO][elapsed: 1075ms][🥇][⓶][coroutname:@JustPrints#3][tname:main/tid:1] a-2
[INFO][elapsed: 1576ms][🥇][⓶][coroutname:@JustPrints#3][tname:main/tid:1] a-3
[INFO][elapsed: 2076ms][🥇][⓶][coroutname:@JustPrints#3][tname:main/tid:1] a-4
[INFO][elapsed: 2577ms][🥇][⓶][coroutname:@JustPrints#3][tname:main/tid:1] a-5
[INFO][elapsed: 3077ms][🥇][⓶][coroutname:@JustPrints#3][tname:main/tid:1] a-6
[INFO][elapsed: 3578ms][🥇][⓶][coroutname:@JustPrints#3][tname:main/tid:1] a-7
[INFO][elapsed: 4079ms][🥇][⓶][coroutname:@JustPrints#3][tname:main/tid:1] a-8
[INFO][elapsed: 4579ms][🥇][⓶][coroutname:@JustPrints#3][tname:main/tid:1] a-9
[INFO][elapsed: 5080ms][🥇][⓶][coroutname:@JustPrints#3][tname:main/tid:1] a-10
[INFO][elapsed: 5581ms][🥇][⓶][coroutname:@JustPrints#3][tname:main/tid:1] ✅ I have FINISHED all of my messages.
[INFO][elapsed: 5581ms][🥇][🧵][tname:main/tid:1] DONE no errors at main.

Calling this.cancel()

too many nested note refs

Key Takeaway

Unless you explicitly use SupervisorJob or supervisorScope, you're working with a scope with regular job that follows structured concurrency principles where one child's failure affects the entire coroutine family.

GT-Sandbox-Snapshot: Example - 1, Cancelling using SupervisorJob

Code

package com.glassthought.sandbox

import gt.sandbox.util.output.Out
import kotlinx.coroutines.*

interface Server {
  suspend fun start()
  suspend fun stop()
}

val out = Out.standard()

class ServerImpl(
  private val scope: CoroutineScope // Injected scope for better control
) : Server {

  private val job = SupervisorJob() // Ensures independent coroutines
  private val serverScope = scope + job

  override suspend fun start() {
    out.info("Starting server")

    serverScope.launch(CoroutineName("ServerWork-1")) {
      out.info("Running server work in thread: ${Thread.currentThread().name}")
      delay(2000)
      out.info("ServerWork-1 completed")
    }

    serverScope.launch(CoroutineName("ServerWork-2")) {
      out.info("Running additional server work in thread: ${Thread.currentThread().name}")
      delay(1500)
      out.info("ServerWork-2  completed")
    }
  }

  override suspend fun stop() {
    out.info("Stopping server")

    job.cancel()
  }
}

fun main() = runBlocking {
  out.info("--------------------------------------------------------------------------------")
  out.info("Example where the server is aborted prior to finishing:")
  runWithDelayBeforeStopping(1000)

  out.info("--------------------------------------------------------------------------------")
  out.info("Example where the server has time to finish:")
  runWithDelayBeforeStopping(3000)
}

private suspend fun runWithDelayBeforeStopping(delayBeforeStopping: Long) {
  val server = ServerImpl(CoroutineScope(Dispatchers.Default)) // Inject external scope
  server.start()
  delay(delayBeforeStopping) // Use delay instead of Thread.sleep
  server.stop()
}

Command to reproduce:

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

Recorded output of command:

[elapsed:   51ms][🥇/tname:main/tid:1][coroutine:unnamed] --------------------------------------------------------------------------------
[elapsed:   62ms][🥇/tname:main/tid:1][coroutine:unnamed] Example where the server is aborted prior to finishing:
[elapsed:   65ms][🥇/tname:main/tid:1][coroutine:unnamed] Starting server
[elapsed:   73ms][⓶/tname:DefaultDispatcher-worker-1/tid:20][coroutine:ServerWork-1] Running server work in thread: DefaultDispatcher-worker-1
[elapsed:   74ms][⓷/tname:DefaultDispatcher-worker-2/tid:21][coroutine:ServerWork-2] Running additional server work in thread: DefaultDispatcher-worker-2
[elapsed: 1084ms][🥇/tname:main/tid:1][coroutine:unnamed] Stopping server
[elapsed: 1087ms][🥇/tname:main/tid:1][coroutine:unnamed] --------------------------------------------------------------------------------
[elapsed: 1087ms][🥇/tname:main/tid:1][coroutine:unnamed] Example where the server has time to finish:
[elapsed: 1087ms][🥇/tname:main/tid:1][coroutine:unnamed] Starting server
[elapsed: 1088ms][⓶/tname:DefaultDispatcher-worker-1/tid:20][coroutine:ServerWork-1] Running server work in thread: DefaultDispatcher-worker-1
[elapsed: 1088ms][⓷/tname:DefaultDispatcher-worker-2/tid:21][coroutine:ServerWork-2] Running additional server work in thread: DefaultDispatcher-worker-2
[elapsed: 2594ms][⓷/tname:DefaultDispatcher-worker-2/tid:21][coroutine:ServerWork-2] ServerWork-2  completed
[elapsed: 3093ms][⓷/tname:DefaultDispatcher-worker-2/tid:21][coroutine:ServerWork-1] ServerWork-1 completed
[elapsed: 4089ms][🥇/tname:main/tid:1][coroutine:unnamed] Stopping server
Cannot re-use scope

https://chatgpt.com/share/31be55da-2e7f-4dc8-9fc3-000384f58086

Sample - 1
import kotlinx.coroutines.*

fun main() = runBlocking {
    val scope = CoroutineScope(Dispatchers.Default)

    val job = scope.launch {
        println("Job started")
        delay(1000)
        println("Job completed")
    }

    delay(500)  // Wait for a while
    scope.cancel()  // Cancel the scope

    // Attempt to launch a new job in the cancelled scope
    val newJob = scope.launch {
        println("New job started")
        delay(1000)
        println("New job completed")
    }

    newJob.invokeOnCompletion { exception ->
        if (exception is CancellationException) {
            println("New job was cancelled due to scope cancellation")
        } else if (exception != null) {
            println("New job completed with exception: $exception")
        } else {
            println("New job completed successfully")
        }
    }

    // Wait for all jobs to complete
    joinAll(job, newJob)
}
Sample - 2
import kotlinx.coroutines.*

fun main() = runBlocking {
    val scope = CoroutineScope(Dispatchers.Default)

    val job = scope.launch {
        println("Job started")
        delay(1000)
        println("Job completed")
    }

    delay(500)  // Wait for a while
    scope.cancel()  // Cancel the scope

    // Create a new scope and launch a new job
    val newScope = CoroutineScope(Dispatchers.Default)
    val newJob = newScope.launch {
        println("New job started")
        delay(1000)
        println("New job completed")
    }

    // Wait for all jobs to complete
    joinAll(job, newJob)
}

Scope functions

coroutineScope: The hero of coroutines.

coroutineScope creates a boundary that establishes a new CoroutineScope.

The new scope inherits its coroutineContext from the outer scope, but overrides the context’s Job. It also causes the current coroutine to suspend until all child coroutines have finished their execution.

The scope created by coroutineScope has a new Job - this creates an exception boundary where failures surface. However, exceptions will continue propagating up the Job hierarchy unless intercepted by exception handling (try-catch, CoroutineExceptionHandler) at the scope boundary.

Coroutine scope functions create an error/exception boundary: Allow us to try-catch the Exception to prevent it from bubbling up to parent Job.

Error boundary example

Without error boundary

launch { // Job A
    launch { // Job B - direct child of A
        throw Exception() // Exception happens in B
                         // B fails and cancels A
                         // NO stopping point at B
                         // Cancels A
    }
}

Moving towards error boundary using coroutineScope

launch { // Job A  
    coroutineScope { // Job B - creates exception boundary
        launch { // Job C - child of B
            throw Exception() // Exception happens in C
                              // C fails, B fails and surfaces exception
                              // CAN be caught at B's boundary (not caught right now)
        }
    }
}

Example creating error boundary try-catch:

launch {
   try {
       // coroutineScope will wait for all the child co-routines to finish.
       coroutineScope {
           launch { 
               delay(1000)
               throw RuntimeException("boom!") 
           }
       }
   } catch (e: CancellationException) {
       // Re-throw CancellationException - do NOT suppress cancellation!
       throw e
   } catch (e: Exception) {
      println("Caught: ${e.message}")
       // Handle other exceptions - stops propagation
       // Coroutine continues normally here
   }
   
   println("Launch continues after exception handling")
}

Details on individual Scope functions:

Another function that behaves a lot like coroutineScope is withTimeout. It also creates a scope and returns a value. Actually, withTimeout with a very big timeout behaves just like coroutineScope. The difference is that withTimeout additionally sets a time limit for its body execution. If it takes too long, it cancels this body and throws TimeoutCancellationException (a subtype of CancellationException).

Beware that withTimeout throws TimeoutCancellationException, which is a subtype of CancellationException (the same exception that is thrown when a coroutine is cancelled). So, when this exception is thrown in a coroutine builder, it only cancels it and does not affect its parent (as explained in the previous chapter). - Kotlin Coroutines Deep Dive

Relationships

Gotchas

too many nested note refs

The withContext function is similar to coroutineScope, but it additionally allows some changes to be made to the scope. The CoroutineContext provided as an argument to this function overrides the context from the parent scope (the same way as in coroutine builders). This means that withContext(EmptyCoroutineContext) and coroutineScope() behave in exactly the same way.

The function withContext is often used to set a different coroutine scope for part of our code. Usually, you should use it together with Dispatcher. - Kotlin Coroutines Deep Dive

Relationships

Gotchas

The supervisorScope function also behaves a lot like coroutineScope: it creates a CoroutineScope that inherits from the outer scope and calls the specified suspend block in it. The difference is that it overrides the context’s Job with Supervisor-Job, so it is not cancelled when a child raises an exception.

supervisorScope is mainly used in functions that start multiple independent tasks. - Kotlin Coroutines Deep Dive

Highlight

  • Does NOT cancel when child raises an exception.
  • Does NOT cancel children when child raises an exception.
  • Waits for ALL children, even failing ones, even after some have failed and threw.
  • Collects ALL exceptions from co-routines.

Notes

Notes

If you need to use functionalities from two coroutine scope func- tions, you need to use one inside another. For instance, to set both a timeout and a dispatcher, you can use withTimeoutOrNull inside withContext. - Kotlin Coroutines Deep Dive

suspend fun calculateAnswerOrNull(): User? =
    withContext(Dispatchers.Default) {
        withTimeoutOrNull(1000) {
            calculateAnswer()
        }
}

Building scope gotchas


Children
  1. Coroutine scope function and Error Boundary
  2. Scope with Regular Job
  3. building-scope-gotchas
  4. cancellation
  5. example
  6. scope-function

Backlinks