This is the second article of a 3 part series on Coroutines. If you haven’t read the first article, I highly suggest you do
After having learned the fundamentals we can finally start using Coroutines in code. We’ll start by learning about Job
and SupervisorJob
and then proceed to creating Coroutines with launch
and async
.
Job
For every coroutine that is created, a Job instance is returned to uniquely identify that coroutine and allow you to manage its lifecycle. You can also create Jobs manually if necessary, for example you might want to give it to a CoroutineContext
to manage the context’s lifecycle.
Conceptually, a job is a cancellable thing with a life-cycle that culminates in its completion. […] A coroutine that threw CancellationException is considered to be cancelled normally. If a cancellation cause is a different exception type, then the job is considered to have failed.
Kotlin Docs
A Job has a define set of states: New, Active, Completing, Completed, Cancelling and Cancelled.
We can’t access the states themselves, but we can access properties of a Job: isActive, isCancelled and isCompleted.
If you pass in a particular Job
or a SupervisorJob
as a context, such as in launch(SupervisorJob())
, it doesn’t cause the work to run in that Job
, it just means it’ll use that Job
as a parent Job for a Job
it’ll construct. Why? Because AbstractCoroutine
implements Job
and all coroutines extend AbstractCoroutine
.
Parent-Child Hierarchy
- When a parent’s job is canceled, the children’s jobs are canceled;
- If we cancel the child’s job, the parent’s job continues;
- When the parent’s job throws an exception, the children’s jobs are canceled too;
- When a child’s job throws an exception, the parent may be canceled;
- Parent cannot complete until all its children are complete;
Supervisor Job
It is similar to a regular Job with the only exception that its children can fail independently of each other.
A child’s failure or cancellation does not result in the supervisor’s job failing or affecting its other children, therefore a supervisor can create a unique policy for dealing with its children’s failures.
A SupervisorJob
only works as described when it’s part of a scope: either created using supervisorScope
or CoroutineScope(SupervisorJob())
. Passing a SupervisorJob
as a parameter of a coroutine builder will not have the desired effect you would’ve thought for cancellation.
Here’s their behavior side by side using launch
:
As you can see, by using a SupervisorJob
, the failure of one child doesn’t affect the other.
Here’s their behavior using async
:
That’s probably not what you were expecting, the reason that happens is that launch
propagates the exception up while await
throws the exception. We’ll take a deeper look that in part III.
Coroutine builders
Coroutine builders are simple functions that generate a new coroutine to execute a given suspending function. They differ from typical non-suspending functions in that they do not suspend themselves and so serve as a link between the normal and suspending worlds.
In the context of runBlocking, the given suspending function and its children in the call hierarchy will effectively block the current thread until it finishes executing. This is often used from the main() function to give a sort of top-level coroutine from which to work, keep the JVM alive while doing so and on tests because you want your tests execution to block until all the work has completed.
There are two main ways to start coroutines, and they have different uses:
Launch
The launch builder will start a new coroutine but won’t wait for its completion, that means it won’t return the result to the caller. launch
acts as a link between ordinary functions and coroutines.
The coroutine context is inherited from the CoroutineScope
but you can specify more elements by passing another context as the context
parameter. Remember that the CoroutineContext
is never overridden, but rather joined with the existing one.
By default, the coroutine is immediately scheduled for execution. You can change that by specifying the start
parameter. You can read about the allowed values here.
If you take a look at the return type, you can see it’s a Job
. That means you can control the coroutine lifecycle by interacting with that Job. You can easily cancel it by calling job.cancel()
.
As we saw earlier, launch
is used a lot on ViewModels to create a bridge from non-suspending code to suspending code.
One thing I see people doing wrong in the beginning is adding a try catch around the launch block. That catch will never run even if an exception is thrown.
You need to remember that when you launch a new coroutine from a non suspending function, you’re basically changing worlds in your code. The code that started the coroutine and the coroutine now run separately.
To fix that you simply need to move your try catch block inside the launch block or add a CoroutineExceptionHandler
to your scope’s context.
Async
The async builder will start a new coroutine and it allows you to get the returned value by calling await
.
The same thing I said about the context
and start
parameters apply here.
await
waits for the block to finish without blocking the thread and resumes when the deferred operation is finished, returning the result or raising the corresponding exception. You might think that wrapping the await
with a try catch would work, it kinda works but not as you expect. What do you think the code below outputs?
Output:
Caught exception
Exception in thread "DefaultDispatcher-worker-1"
That looks very confusing the first time you look at it, here’s what happening:
- The coroutine launched by
async
throws an exception - The exception is caught and “Caught exception” is printed
- The coroutine delegates the exception handling to its parent
- The parent is cancelled because it has no exception handler
How do you correctly handle exceptions then? You need to wrap the await
call with supervisorScope
to prevent the exception from propagating up.
You’ll understand why you need to do that later when we take a deeper look at exception handling. Now let’s try another thing, what happens if we use a SupervisorJob
on the root scope?
Output:
Caught exception
Exception in thread "DefaultDispatcher-worker-1" java.lang.Exception
Caught exception
Exception in thread "DefaultDispatcher-worker-2" java.lang.Exception
It’s very similar to a normal job with one exception
- The coroutine launched by
async
throws an exception - The exception is caught and “Caught exception” is printed
- The coroutine delegates the exception handling to its parent
- The parent is NOT cancelled because it’s a
SupervisorJob
, only the child is
They’re different but not that much
Both launch
and nested async
builders do not re-throw exceptions that occur inside them. Instead, they propagate them up the coroutine hierarchy. As we saw earlier child coroutines always delegate the exception handling to its parent.
coroutineScope
CoroutineScope
with a capital C and coroutineScope
with a tiny c are two different things. Here we’ll talk about the one that’s a function call.
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.
It’s very useful when we need to start new coroutines in a structured way inside a suspend function without access to the outer scope. What’s really cool is coroutineScope
will create a child scope. So if the parent scope gets cancelled, it will pass the cancellation down to all the new coroutines.
For example the code below doesn’t work because async
and launch
are extensions on CoroutineScope
How can you fix that? You can use coroutineScope
to create a scope inside a suspend function and still maintain structured concurrency.
This function is designed for parallel decomposition of work. When any child coroutine in this scope fails, this scope fails and all the rest of the children are cancelled. It’s useful when you have many async blocks that must be canceled if any of them fails.
Kotlin Docs
Remember that an exception will also cancel the parent’s scope if you don’t use a SupervisorJob
.
supervisorScope
The behavior of supervisorScope
is defined as: a failure of a child does not cause the scope to fail and does not affect its other children.
Well, what does that mean?
What does this code output? By looking at the definition you might think it outputs nothing because a failure of a child shouldn’t cause its scope to fail.
Here’s the output:
Exception in thread "main" java.lang.Exception
Exception in thread "main" java.lang.Exception
That’s pretty interesting, the exception is propagated up and the scope gets cancelled. Even though it sounds like you can crash child coroutines as much as you want, that’s not entirely true. Exceptions still get propagated upwards, and if parent supervisorScope
doesn’t have some error handling mechanism present, it’s still gonna get cancelled.
So, how do we solve it? We can simply add a CoroutineExceptionHandler
to the CoroutineContext
.
Another neat feature about supervisorScope
is that Coroutines created inside it become top-level Coroutines, allowing us to install a CoroutineExceptionHandler
in them.
This code has the expected output, nothing is outputted.
In this article we learned about Job
and SupervisorJob
and most importantly how they work. We also saw that launch
and async
can be used to create coroutines and learned how they differ from each other. Now that we know the basic about Coroutines we can proceed to more advanced concepts, stay tuned for part III.
If you have any doubts, feel free to contact me. See you in the next article 😉
Resources
Are You Handling Exceptions in Kotlin Coroutines Properly?
Kotlin Coroutine Job Lifecycle
Cover photo by Dominik Vanyi on Unsplash