Alkis Mavridis
Alkis Mavridis

Reputation: 1211

How does exception propagation works on CoroutineScope.async?

I see multiple sources claiming that an exception happening inside an async{} block is not delivered anywhere and only stored in the Deferred instance. The claim is that the exception remains "hidden" and only influences things outside at the moment where one will call await(). This is often described as one of the main differences between launch{} and async{}. Here is an example.

An uncaught exception inside the async code is stored inside the resulting Deferred and is not delivered anywhere else, it will get silently dropped unless processed

According to this claim, at least the way I understand it, the following code should not throw, since no-one is calling await:

// throws
runBlocking {
  async { throw Exception("Oops") }
}

And yet, the exception gets thrown. This is also discussed here, but I could not really understand why by reading this.

So it seems to me that when async throws, a "cancelation signal" is propagated on the parent scope, even if await() does not get called. Aka the exception does not really remain hidden, nor silently dropped, as the quote above states. Is my assumption correct?

Now, if we pass a SupervisorJob(), the code does not throw:

// does not throw
runBlocking {
  async(SupervisorJob()) { throw Exception("Oops") }
}

This seems reasonable since supervisor job is meant to swallow failures.

And now comes the part I do not understand at all. If we pass Job(), the code still runs without throwing, even though Job() is supposed to propagate failures to its parent scope:

// does not throw. Why?
runBlocking {
  async(Job()) { throw Exception("Oops") }
}

So my question is, why passing no Job throws, but passing either Job or SupervisorJob does not throw?

Upvotes: 10

Views: 1348

Answers (1)

Marko Topolnik
Marko Topolnik

Reputation: 200296

In some sense, the mess you experience is a consequence of Kotlin coroutines having been an early success, before they became stable. In their experimental days, one thing they lacked was structured concurrency, and a ton of web material got written about them in that state (such as your link 1 from 2017). Some of the then-valid preconceptions remained with people even after their maturation, and got perpetuated in even more recent posts.

The actual situation is quite clear — all you have to understand is coroutine hierarchy, which is mediated through the Job objects. It doesn't matter whether it's a launch or an async, or any further coroutine builder — they all behave uniformly.

With this in mind, let's go through your examples:

runBlocking {
  async { throw Exception("Oops") }
}

By writing just async you implicitly used this.async, where this is the CoroutineScope that runBlocking established. It contains the Job instance associated with the runBlocking coroutine. For this reason, the async coroutine becomes the child of runBlocking, so the latter throws an exception when the async coroutine fails.

runBlocking {
  async(SupervisorJob()) { throw Exception("Oops") }
}

Here you supply a standalone job instance which has no parent. This breaks the coroutine hierarchy and runBlocking does not fail. In fact, runBlocking doeesn't even wait for your coroutine to complete — add a delay(1000) to verify this.

runBlocking {
  async(Job()) { throw Exception("Oops") }
}

No new reasoning here — Job or SupervisorJob, it doesn't matter. You broke the coroutine hierarchy and the failure doesn't propagate.

Now let's explore a few more variations:

runBlocking {
    async(Job(coroutineContext[Job])) {
        delay(1000)
        throw Exception("Oops")
    }
}

Now we created a new Job instance, but we made it a child of runBlocking. This throws an exception.

runBlocking {
    async(Job(coroutineContext[Job])) {
        delay(1000)
        println("Coroutine done")
    }
}

Same as above, but now we don't throw an exception and the async coroutine completes normally. It prints Coroutine done, but then something unexpected happens: runBlocking does not complete, and the program hangs forever. Why?

This is maybe the trickiest part of this mechanism, but it still makes perfect sense once you think it through. When you create a coroutine, it internally creates its own Job instance — this happens always, whether or not you explicitly supply a job as an argument to async. If you supply an explicit job, it becomes the parent of that internally-created job.

Now, in the first case, where you didn't provide an explicit job, the parent job is the one internally created by runBlocking. It automatically completes when the runBlocking coroutine completes. But completion doesn't propagate to the parent the way cancellation does — you wouldn't want everything stopping just because one child coroutine completed normally.

So, when you create your own Job instance and supply it as the parent of the async coroutine, that job of yours isn't getting completed by anything. If the coroutine fails, the failure propagates to your job, but if it completes normally, your job eternally stays in the original state of "in progress".

And finally, let's bring in SupervisorJob again:

runBlocking {
    async(SupervisorJob(coroutineContext[Job])) {
        delay(1000)
        throw Exception("Oops")
    }
}

This just runs forever without any output, because SupervisorJob swallows the exception.

Upvotes: 15

Related Questions