In this blog post we’re going to dive into some small examples of how to exploit Kotlin Coroutines’ power regarding concurrent decomposition and, as small sprinkles on top, we’ll look at a nice trick to show how you can exploit the bounded nature of coroutines to your favor; and for those interested I’ll present some theoretical background in the last part.
A coroutine is not a thread replacement
Intuition says that if I want something to happen concurrently, start a coroutine:
scope.launch { // do something }
A coroutine can have a start and an end just like a thread does. So what’s the difference to a thread? A coroutine can be bounded while a thread isn’t. So the real sauce is when you use that to your benefit.
Show me the sauce!
Let’s say we have some code that interacts with two services. The job is to retrieve account details from an account service while also doing some tracking.
- First, we check the account service whether it’s available.
- Only if that applies,
- we ping the tracking service (if this doesn’t work, we ignore the failure).
- and we retrieve the account details of all requested accounts from the account service.
- We do as many calls as possible in parallel to save time for the user.
- If one call fails, all of them are cancelled
- and we want to return to the caller without waiting for further network responses.
In Kotlin Coroutines this is how you implement that:
suspend fun getAccountDetails(accounts: List<Account>): List<AccountDetails> { return coroutineScope { accountService.checkAvailability() launch { runCatching { trackingService.accountDetailsRetrieval() } } accounts .map { async { accountService.getAccountDetails(it.accountId) } } .awaitAll() } }
This code is short, easy to write and expressive. Just imagine you’d have to use callbacks and threads for that!
Let’s break it down
accountService.checkAvailability()
We just call this function without catching exceptions, as if an exception occurs we don’t want to continue in the flow anyway — perfect!
launch { runCatching { trackingService.accountDetailsRetrieval() } }
Here a coroutine is launched which attempts to ping the tracking service. We wrap the code inside runCatching { } so if something goes wrong here we ignore it — exactly as specified!
Shortly after, we call
async { accountService.getAccountDetails(it.accountId) }
for each account. This also launches coroutines, one for each account, meaning: all that is happening in parallel, including the call to the tracking service. This saves a lot of time, great!
map { ... }
What does this expression return? It’s a list of Deferred<AccountDetails>, each of them representing a promise to return an AccountDetails object in the future. Calling awaitAll() suspends the function until all those promises are fulfilled and returns the concrete list of AccountDetails from the backend.
coroutineScope – huh?
But what’s with the elephant in the room? We wrapped the whole content of the function in a coroutineScope { } block. Why is that? The aim is to give all parallelism happening a container. That has multiple great benefits:
Concurrent decomposition
By using coroutineScope { }, the suspending function can spread out to multiple concurrent execution strands. Only when the block and all started coroutines are completed, coroutineScope { } returns. No need to manually write code that tells someone to wait. That’s all pretty convenient!
Cancellation
Whenever an exception happens, coroutineScope { } makes sure a sane default cancellation behavior is followed. First, it cancels all coroutines started inside it, meaning: if there are pending calls, they are ignored. Second, it re-throws the first exception that occurred. All that happens as fast as possible, no additional waiting for network responses — no time wasted!
Note, though, that cancellation is cooperative: if the underlying code doesn’t support cancellation or you use withContext(NonCancellable) { … } cancellation can still take time.
I think by now it should be clear what the concept of a coroutine scope is: a way to give more complex execution flows a container, enabling convenient suspension and cancellation behavior. But:
What is a Job?
If a coroutine scope is something that can contain work being started, a Job represents a piece of concrete work that has started at runtime. So every time you launch or async some work, a Job is created and returned immediately, although the work itself can still take some time to complete. async gets you a Deferred<T> that can contain a result value, but launch returns only a Job which contains no result value. Both are a kind of Job, though.
Mostly we ignore the Job returned from launch or async. So why should we be interested in it? Let’s dive into a useful example you can use in your everyday work!
Excursus: exploiting Job to automate loading state
Jobs contain the information whether some piece of work is finished or not. You can use this information in order to wait for completion, e.g. with join(), await() or awaitAll(). But you can also use the information in a different way: to automate updating the “loading” state of a view!
Usually, this is what you see in view models:
data class State(val loading: Boolean, val data: String) val state = MutableStateFlow(State()) suspend fun loadState() { state.update { it.copy(loading = true) } val data = getFromBackend() state.update { it.copy(loading = false, data = data) } }
This is error-prone:
- What if a colleague forgets to reset the loading state? Then the UI would be stuck in that state, rendering the UI useless.
- What if an exception happens? The same mistake would happen then.
Let’s imagine a tool that takes care about setting the loading state automatically, for example like this:
async { ... } .trackWith(jobTracker)
The idea is that the loading state is always automatically true while any job is running, no matter how many there are, even in parallel. For that, we need some utility that maintains a list of all jobs and determines its internal state by any of those jobs being active. I have created a little helper for that and called it JobTracker. And here’s how you set it up:
val jobTracker = JobTracker { isActive -> state.update { it.copy(loading = isActive) } }
Every time you start concurrent work, tie it to the JobTracker and you’re set, for example in your view model:
viewModelScope.launch { ... }.trackWith(jobTracker)
There’s also an extension you can use in case you need to track the completion of a suspending function:
jobTracker.coroutineScope { async { ... } launch { ... } }
The extension function simply acts like the original Kotlin coroutineScope function but additionally reports the active state to the JobTracker.
To conclude, this example demonstrates how you can effectively exploit coroutine scopes and jobs in order to not only coordinate asynchronous work but also to track it. You can have a look at this little helper here. Feel free to use it in your code!
A bit of theoretical background
Why is there structured concurrency? Concurrency and asynchronicity can be messy. There are two concepts that are preceding coroutines: threads and callbacks.
Threads
Everyone can start threads. But coordination between them is sometimes hard. How do you make sure that, as soon as three threads are done with their jobs, the main thread confidently continues where the three other ones left off? And how (or rather: who) makes sure those three threads are freed up after their work? All that coordination between threads is error-prone and complex.
Callbacks
These are one way to return to the caller, eventually handing over data. The problem is that if the consumer of some callback API forgets to wire it up incorrectly, it can end up with a workflow being stuck.
For example, if an exception happens in a callback or a thread, you always need to think about the correct exception handling, signalling a specific state to other threads or callbacks. Without that, the original caller of an API or thread will never know something wrong happened.
Not to mention all the annoying plumbing you need to do repetitively! So the idea with structured concurrency was to create a layer of abstraction that doesn’t deal with low-level concepts like threads and callbacks but with concepts like “branching off work” and “joining” it (alongside others like reactive flows and suspending/async functions, of course).
If you give it some thought, it’s crazy we lived with only threads and callbacks for such a long time. It is literally like using goto statements in code as we used to until the 1950s where we had no functions and return statements. Back then, if you jumped to a point in program memory, nobody ensured that the program flow would return to its origin except if you did it deliberately. As you can imagine, that was extremely error-prone. If you are interested in the analogy between using old-style threads with using goto statements, look at this great in-depth article by Nathaniel J. Smith.
Conclusion
Seeing coroutines just as a way to parallelize work is overly simplistic. Amongst other things, it’s a lightweight abstraction over threads with a structured concurrency toolset.
Coroutines are a way of modeling complex concurrent and asynchronous behavior in a way it’s easy to both read and write, dramatically diminishing the chance of unintended errors and misunderstandings. Like the old goto statements being replaced by subroutines, it’s another tool that reduces complexity for the programmer in order to be able to achieve greater functionality overall.
No matter what’s said about AI, it’s still a great time to be a programmer if you find enjoyment in improving on and understanding your craft, so let’s use those extraordinary tools we have 😉
