Advanced Kotlin Coroutines tips and tricks
Learn about a few snags and how to get around them
Published Oct 30, 2018 • Last updated Jun 17, 2020 • 5 min read
Kotlin Coroutines starts off incredibly simple: just put some long running operation in
launch and you’re good, right? For simple cases, sure. But pretty soon, the complexity inherent to concurrency and parallelism starts piling up.
Here’s what you need to know when you’re knee deep in the coroutine trenches.
Cancellation + blocking work = 😈
There’s no way to get around it: you’ll have to use good ol’ Java streams at some point or another. One problem (of many 😉) with streams is that they block the current thread. That’s bad news in the coroutines world. Now, if you want to cancel a coroutine, you’ll have to wait for the read or write to complete before you can continue.
As a simple reproducible example, let’s say you open a
ServerSocket and wait for a connection with a 1 second timeout:
Now you’re feeling a bit like this: 😖. So how do we fix it?
Closeable APIs are well built, they support closing the stream from any thread and will fail appropriately.
Note: in general, APIs from the JDK follow those best practices, but beware of any third party
CloseableAPIs that may not. You’ve been warned.
Thanks to the
suspendCancellableCoroutine function, we can close any stream when a coroutine is cancelled:
Now that our blocking
accept call is wrapped in
useCancellably, the coroutine will fail when the timeout occurs.
But what if you can’t support cancellation at all? Here’s what you need to watch out for:
- If you use any instance properties/functions from your coroutine’s enclosing class, it will be leaked even if you cancel the coroutine. This is especially relevant if you think you’re cleaning up resources in
onDestroy. Workaround: move the coroutine to a
ViewModelor other non-context class and subscribe to its result.
- Make sure to use
Dispatchers.IOfor blocking work since that allows Kotlin to set aside some threads that it expects to be waiting indefinitely.
Since the top SO answers about these two builders are out-of-date, I thought I’d touch upon their differences again.
launch bubbles up exceptions
When a coroutines crashes, its parent is cancelled which in turn cancels all the parent’s children. Once coroutines throughout the tree have finished cancelling, the exception is sent to the current context’s exception handler. On Android, that means your app will crash, regardless of what dispatcher you were using.
async holds on to its exceptions
await() explicitly handles all exceptions and installing a
CoroutineExceptionHandler will have no effect.
launch “blocks” the parent scope
While the function will return immediately, its parent scope will not finish until all coroutines built with
launch have completed one way or another. This makes calling
join() for all your child jobs at the end of the parent unnecessary if you simply want to wait for those coroutines to finish.
Unlike what you might expect, the outer scope will still wait for
async coroutines to complete even if
await() is not called.
async returns a result
This one’s pretty simple: if you need a result out of your coroutine,
async is your only option. If you don’t need a result, use
launch to create side effects. And only if you need those side effects to complete before moving on do you need to use
join() does not rethrow exceptions while
await() will. However,
join() cancels your coroutine if an error occurred, meaning any code after the suspending call to
join() is not invoked.
Now that you understand how differently exceptions are handled depending on which builder you use, you’re left with a dilemma: you want to log exceptions without crashing (so we can’t use
launch), but you don’t want to manually
catch them all (so we can’t use
async). So that leaves us with… nothing? Thankfully not.
Logging exceptions is where the
CoroutineExceptionHandler comes in handy. But first, let’s take a moment to understand what actually happens when an exception is thrown in a coroutine:
- The exception is caught and then resumed through a
- If your code doesn’t handle the exception and it isn’t a
CancellationException, the first
CoroutineExceptionHandleris requested through the current
- If a handler isn’t found or it errors, the exception is sent to platform specific code.
- On the JVM, a
ServiceLoaderis used to locate global handlers.
- Once all handlers have been invoked or one of them errors, the current thread’s exception handler gets invoked.
- If the current thread doesn’t handle the exception, it bubbles up to the thread group and then finally to the default exception handler.
With that in mind, we have a few options:
- Install a handler per thread, but that’s not realistic.
- Install the default handler, but then errors from the main thread won’t crash your app and you’ll be left in a potentially bad state.
- Add the handler as a service which will be invoked when any coroutine built with
- Use your own custom scope with a handler attached instead of
GlobalScopeor add the handler to every scope you use, but that’s annoying and makes logging optional instead of the default.
That last solution is preferred because it is flexible while requiring minimal code and hacks.
For app wide jobs, you’ll use an
AppScope with a logging handler. For any other jobs, you can add the handler when logging is appropriate over crashing.
Anytime we have to deal with edge cases, things get messy pretty fast. I hope this article helped you understand the variety of problems you can run into given subpar conditions and what potential solutions you can apply.