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:
- Uncaught, non-cancellation exception → Cancels Siblings + Propagates recursively through parent Job hierarchy, with each parent shutting down its children, Effectively shutting down the entire hierarchy up to the top most Job (or up to where you caught exception at error boundary).
- Cancels siblings: Cancellation Cooperative Functions/suspension point in sibling functions will throw CancellationException
- Propagates to parent: Parent scope will rethrow the original exception that was thrown in co-routine.
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.
Creating Error Boundary
- Look at Coroutine scope function and Error Boundary to create error/ boundaries which allows us to try/catch exceptions stopping them from going higher up.
Gotchas
too many nested note refs
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.
State | isActive | isCompleted | isCancelled |
---|---|---|---|
New (optional initial state) | false | false | false |
Active (default initial state) | true | false | false |
Completing (transient state) | true | false | false |
Cancelling (transient state) | false | false | true |
Cancelled (final state) | false | true | true |
Completed (final state) | false | true | false |
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:
- Uncaught, non-cancellation exception → Cancels Siblings + Propagates recursively through parent Job hierarchy, with each parent shutting down its children, Effectively shutting down the entire hierarchy up to the top most Job (or up to where you caught exception at error boundary).
- Cancels siblings: Cancellation Cooperative Functions/suspension point in sibling functions will throw CancellationException
- Propagates to parent: Parent scope will rethrow the original exception that was thrown in co-routine.
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.
Creating Error Boundary
- Look at Coroutine scope function and Error Boundary to create error/ boundaries which allows us to try/catch exceptions stopping them from going higher up.
Gotchas
- ⚠️ Firstly, make sure to understand intended Exception/Cancellation Behavior with Regular Job ⚠️.
- ⚠️ Swallowing cancellation exception prevents cancellation ⚠️
- ⚠️ Unhandled exception from async can go to parent scope without going through await() ⚠️
- ⚠️ cancel() call stops on next suspension point, NOT right away ⚠️
- ⚠️ join() does NOT rethrow ⚠️
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:
See Coroutine scope function and Error Boundary for detailed info on scope functions.
- Inherits a context from its parent, while creating a new Regular Job that inherits from parent Job.
- Uncaught Exception from one child:
- Waits for all its children before it can finish itself (when children do not throw)
- Cancels all its children when the parent is cancelled
too many nested note refs
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
- Coroutine scope function and Error Boundary
- Scope with Regular Job
- building-scope-gotchas
- cancellation
- example
- scope-function
Backlinks