coroutineScope() async vs launch cancellation

Hello, I am reading the Kotlin In Action: 2nd Edition book.
And I have a question regarding coroutineScope{ } cancellation.

If I use async - everything is intuitive, and according to the book - the coroutineScope also cancels all its child coroutines:

import kotlinx.coroutines.*
import kotlin.time.Duration.Companion.seconds

fun main() {
    runBlocking(Dispatchers.Default) {
        var d2: Deferred<Int> = async { 999999 }
        launch {
            delay(1.seconds)
            d2.cancel()
        }
        val result = coroutineScope {
            val d1 = async {
                delay(2.seconds)
                2 + 2
            }
            d2 = async {
                delay(2.seconds)
                3 + 3
            }
            d1.await() + d2.await()
        }
        println(result)
    }
}

Results in console:

Exception in thread "main" kotlinx.coroutines.JobCancellationException: DeferredCoroutine was cancelled; job=DeferredCoroutine{Cancelled}@3abfe836

Process finished with exit code 1

However, using launch, I don’t get the coroutineScope {} function cancelling all its children:

import kotlinx.coroutines.*
import kotlin.time.Duration.Companion.seconds

fun main() {
    runBlocking(Dispatchers.Default) {
        var d2: Job? = null
        launch {
            delay(2.seconds)
            d2?.cancel() ?: println("NULL")
        }
        val result = coroutineScope {
            launch {
                while (true) {
                    delay(333)
                    println("1")
                }
            }
            d2 = launch {
                try {
                    while (true) {
                        delay(333)
                        println("2")
                    }
                } catch (e: CancellationException) {
                    println("CANCELLED")
                    throw e
                }
            }
            321
        }
        println(result)
    }
}
...
2
1
2
1
CANCELLED
1
1
1
...continues printing just "1"

The key line is throw e where I expect the CancellationException to be rethrown up to the coroutineScope{} so it can cancel all its child coroutines but it doesn’t happen.

The main question is: Why? Why doesn’t the coroutineScope {} function cancel all child launch coroutines as it does with the async coroutines in the async case?

Could you reduce your example or at least elaborate on where/what exactly is the confusing part? You say something about coroutineScope not cancelling its children, but in both examples you never cancel the coroutineScope.

Cancellations are not propagated to parent coroutines. It wouldn’t make sense if they would. You started a subtask, then you decided you don’t need it anymore, you cancel it, then it cancels you back.

Yes, of course, here is simplified example of my misunderstanding:

import kotlinx.coroutines.*
import kotlin.time.Duration.Companion.seconds

fun main() {
    runBlocking(Dispatchers.Default) {
        coroutineScope {
            launch {
                while (true) {
                    delay(1.seconds)
                    println("Child #1 is working")
                }
            }
            launch {
                delay(5.seconds)
                println("Child #2 is cancelled")
                coroutineContext.job.cancel()
                // at this point, the coroutineScope{} should have
                // cancelled each coroutineScope child, but it did not
            }
        }
    }
}

The main issue is that in the simillar example when I use async instead of launch, async does get cancelled when I call cancel() on sibling, however, using launch the sibling (Child #1) doesn’t get cancelled for some reason, but I did read from the book that coroutineScope {} cancels each its child if one of the siblings is cancelled.

What do I understand incorrectly?

How do you observe it works differently with async? It shouldn’t and it doesn’t. I used your above code, switched to async and it works exactly the same.

Again, the reason is that you cancelled the “Child #2”, you never cancelled the parent coroutine nor “Child #1”.

1 Like

Oh yes, thank you very much. I just have adjusted the async code to be more evident and obvious, and now I see that both the async and launch parts are working the exact same way!

import kotlinx.coroutines.*
import kotlin.time.Duration.Companion.seconds

fun main() {
    runBlocking(Dispatchers.Default) {
        val result = coroutineScope {
            val d1 = async {
                repeat(10) {
                    delay(1.seconds)
                    println("Async#1 is preparing to yield a value")
                }
                1
            }
            val d2 = async {
                delay(2.seconds)
                println("Async#2 is cancelling itself")
                this.coroutineContext.job.cancel()
                2
            }
            d1.await() + d2.await()
        }
        println(result)
    }
}

d1 never stops even after cancellation of d2 - this is exactly the same as in the example with launch.

What I now have figured out is that coroutineScope treats differently a CancellationException and any other exception, e.g. UnsupportedOperationException does cancel the coroutineScope, while a CancellationException only cancels the one coroutine itself but no children are affected by this CancellationException.

Please tell me if I got it right.

Yes, this is correct. Cancellations are technically implemented using exceptions, but they are not considered a failure state, so they don’t propagate to parents.

When a coroutine is cancelled using Job.cancel, it terminates, but it does not cancel its parent.

If a coroutine encounters an exception other than CancellationException, it cancels its parent with that exception.

2 Likes

Thank you very much! The thread can be closed.