This is the third and final article of a 3 part series on Coroutines. If you haven’t read the first two articles, I highly suggest you do:
Handling Cancellation
Coroutines are cancelled cooperatively by throwing a CancellationException
. Exception handlers that catch a top-level exception like Throwable
will catch this exception. If you swallow the exception in an exception handler or never suspend, the coroutine will stay in a semi-canceled state.
If you’re catching Exception
on your try catch block make sure you rethrow CancellationException
. That way the calling code also gets notified of the cancellation.
CoroutineExceptionHandler
Another way to handle exceptions is by attaching an exception handler on CoroutineContext
.
CoroutineExceptionHandler
is a last-resort mechanism for global “catch all” behavior. You cannot recover from the exception in the CoroutineExceptionHandler
.
All children coroutines delegate handling of their exceptions to their parent coroutine, which also delegates to the parent, and so on until the root, so the CoroutineExceptionHandler
installed in their context is never used.
CoroutineExceptionHandler
is invoked only on uncaught exceptions.
CoroutineExceptionHandler
has no effect on async
. A coroutine that was created using async always catches all its exceptions and stores them in the resulting Deferred object, so it cannot result in uncaught exceptions.
When multiple children of a coroutine fail with an exception, the first exception gets handled.
“I cancelled the coroutine and it didn’t stop”
Calling cancel
does not guarantee that the coroutine operation will be terminated. There is nothing that will immediately stop your code from executing if you are running a somewhat large operation, such as reading from many files.
All kotlinx.coroutine
suspend functions are cancellable. So, if you are using them, you don’t need to check for cancellation. However, if you are not utilizing them, you have two alternatives for making your coroutine code cooperative:
- Checking
job.isActive
orensureActive()
. According to the docs: “Ensures that current scope is active. If the job is no longer active, throws CancellationException.“
- Use
yield()
to let other work run. According to the docs: “Yields the thread (or thread pool) of the current coroutine dispatcher to other coroutines on the same dispatcher to run if possible.“
You might think you should never write while(true)
in your code but when you’re using coroutines that’s something common. Take a look a the code below, the loop will run forever only if the calling coroutine never gets cancelled and that’s probably the intentional behavior. However, if you cancel the calling coroutine, this loop is going to stop because delay
checks for cancellation and throws CancellationException
if the coroutine was cancelled.
Cancellation Process
We know coroutines are cancelled by throwing CancellationException
but how does the cancellation process occur?
If you’re using a Job
, the coroutine:
- Cancels the rest of its children
- Cancels itself
- Propagates the exception up to its parent
If you’re using a SupervisorJob
, the coroutine:
- Cancels itself (the failure of a child doesn’t affect other children)
If the exception is not handled and the CoroutineContext
doesn’t have a CoroutineExceptionHandler
, it will reach the default thread’s ExceptionHandler
.
Dealing with side effects of cancellation
Assume you need to do a certain action when a coroutine is cancelled, such as closing any resources you may be utilizing, logging the cancellation, or running some cleanup code. There are several ways we can do this:
- Check for !isActive (CoroutineScope.isActive)
- Try catch finally
- If the cleaning task we need to conduct is suspending, the code above will no longer function since the coroutine cannot suspend when it is in the Cancelling state.
- To be able to run suspend functions when a coroutine is cancelled, we must change the cleanup work to run in a
NonCancellable
CoroutineContext.
suspendCancellableCoroutine
and invokeOnCancellation
Be careful with NonCancellable
NonCancellable
creates a job that is never cancelled and is always active. It is intended to be used with the withContext
function to prevent cancellation of code blocks that must be performed without cancellation.
This object is not intended to be used with coroutine builders such as launch
, async
, and others. If you write launch(NonCancellable){ ... }
, not only will the freshly launched job not be terminated when the parent is cancelled, but the whole parent-child relationship between parent and child will be destroyed. The parent will not wait for the child to finish, nor will it be cancelled if the child crashes.
Structured Concurrency
You’ll be launching a lot of coroutines in real world applications. Structured concurrency guarantees they are not lost or leaked. It is a collection of language features and best practices that, when combined, allow you to keep track of all work done in coroutines.
Except for runBlocking
, all coroutine builders are declared as extensions of CoroutineScope
, let’s understand why.
The main uses of structured concurrency are:
- Cancel work when it is no longer needed.
- Keep track of work while it’s running.
- Signal errors when a coroutine fails.
If you follow these guides you can be sure that you’ll be avoiding many confusing problems that might appear by not following them.
Cancel work with scopes
Cancellation is important for avoiding doing more work than needed which can waste memory and battery life; proper exception handling is key to a great user experience.
If an action takes too long to finish, an average user either leaves the associated UI element and moves on, or, worse, reopens this UI and attempts the action again. If nothing is done, the preceding action will still be running in the background, causing a leak.
On Android, it often makes sense to associate a CoroutineScope
with a user screen. If you’re using coroutines on your Activity, Fragment or ViewModel use the scopes we saw earlier (lifecycleScope
and viewModelScope
). The advantage of that is that you don’t need to worry about canceling work when the user leaves the screen.
But remember, your coroutine will only stop running if it’s cooperative.
TL;DR: When a scope cancels, it also cancels all of its coroutines.
Keep track of work
Structured concurrency guarantees that when a suspend function returns, all of its work is done.
coroutineScope
and supervisorScope
let you safely launch coroutines from suspend functions and only return when all of its children have completed.
TL;DR:When a suspended fun returns, it has completed all of its work..
Signal errors
Structured concurrency guarantees that when a coroutine errors, its caller or scope is notified.
Exceptions from a suspend
function will be re-thrown to the caller by resume. Just like with regular functions, you’re not limited to try/catch to handle errors and you can build abstractions to perform error handling with other styles if you prefer.
If a coroutine started by coroutineScope
throws an exception, coroutineScope
can throw it to the caller. Since we’re using coroutineScope
instead of supervisorScope
, it would also immediately cancel all other children when the exception is thrown.
You can create unstructured concurrency by introducing a new unrelated CoroutineScope
(note the capital C
), or by using a global scope called GlobalScope
, but you should only consider unstructured concurrency in rare cases when you need the coroutine to live longer than the calling scope. It’s a good idea to then add structure yourself to ensure you keep track of the unstructured coroutines, handle errors, and have a good cancellation story.
TL;DR:When a coroutine fails, the caller or scope of the coroutine is alerted.
Dispatchers.Main.immediate
If you browse through kotlinx.coroutines you’ll probably see something like Dispatchers.Main.immediate
. What does immediate
do? It is used to execute the coroutine immediately without needing to re-dispatch the work to the appropriate thread. In Android launch(Dispatchers.Main)
posts a Runnable in a Handler, so its code execution is not immediate. Let’s see that in action
Output:
Before After Inside
By switching to Dispatchers.Main.immediate
the code works as expected
Output:
Before Inside After
Coroutines best practices
Inject Dispatchers into classes
- Simplifies testing since they can be replaced during unit and instrumentation tests.
The layers below the presentation layer should expose suspend functions and Flows
- The caller (usually the presentation layer) has control over the execution and lifespan of the work being done in those levels, and can cancel as necessary.
Avoid GlobalScope
- Promotes hard-coding values. It might be tempting to hardcode
Dispatchers
if you useGlobalScope
straight-away. - It makes testing very hard. You will be unable to control the execution of work initiated by your code because it will be conducted in an uncontrolled scope.
When you need anything to execute outside of its current scope, it’s best to create a new scope in your Application class and run coroutines within it. For this type of work, avoid utilizing GlobalScope
and NonCancellable
.
This was the 3rd and final article of the Coroutines series. It was definitely a lot of content to process but after having written this article I’m very happy with the things I’ve learned and I hope you are too. There’s much more to Coroutines but I already got bored 😆 of this topic and now it’s time for me to venture into something new.
If you have any doubts, feel free to contact me.
Further Learning
Coroutines & Patterns for work that shouldn’t be cancelled
Things I Misunderstood About Kotlin Coroutine Cancellations and Exceptions
Photo by Alain Pham on Unsplash