Reputation: 1211
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
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