Kotlin Coroutines 101: Async Programming in Practice
Hello folks ✌🏼😎
As you start learning Kotlin or Android you will face the need to offload heavy tasks from the UI Thread. You won't like your users to complain about your app to be slow!
Embark on a journey from Kotlin coroutines novice to expert with this comprehensive article. We’ll guide you through the essential concepts, providing practical code samples every step of the way.
Table of Concepts
- Understanding Coroutines: A Primer
- Kotlin Coroutines Components
- Delving into CoroutineContext: Understanding the Core
- Unveiling the CoroutineScope: Your Gateway to Control
- Coroutine-builders: Creating Coroutines
- Launching Coroutines: Real-world Applications
- Wrapping up
Understanding Coroutines: A Primer
A coroutine is a programming technique that allows you to pause and resume the execution of a function at specific points without blocking the entire program.
Think of a coroutine as a helpful assistant that can handle multiple tasks at once, allowing your program to stay responsive and performant even during complex operations.
This is how you launch a new coroutine. Run it and see what it prints.
The suspend function
A suspend function in Kotlin is a function that can be paused and resumed at a later time without blocking the thread on which it’s called. It’s typically used within coroutines to perform asynchronous or long-running operations without blocking the program’s execution.
Suspend functions can only be called from other suspend function or from a coroutine. Let's analyze the use of the suspend function someHeavyTask()
.
You won't notice any difference from this to calling any other normal function. That's why according to the docs, suspend functions are sequential by default (docs).
💡 Note:
runBlocking
is a helper function, useful for testing purposes. It runs a new coroutine, wait for the completion of any inner work and block the current thread.
Kotlin Coroutines Components
The Kotlin coroutines library comprises several components, each serving a distinct purpose. Below, you’ll find a diagram illustrating the interconnections between these components:
In the next sections we are diving into these concepts. We'll cover it from bottom to top, connecting the dots between them as we show practical samples.
Delving into CoroutineContext: Understanding the Core
The word Context refers to the circumstances or conditions in which something exists or occurs.
A Kotlin coroutine context provides the execution context and various elements needed for a coroutine to run. It essentially defines the environment in which a coroutine operates, such as its threading behavior and other runtime characteristics.
The coroutine context can consist of one of more of these elements:
- Dispatcher
- Job
- CoroutineExceptionHandler
- CoroutineName
Dispatchers
Here's a definition from the documentation:
Dispatchers determines what thread or threads the corresponding coroutine uses for its execution. The coroutine dispatcher can confine coroutine execution to a specific thread, dispatch it to a thread pool, or let it run unconfined.
Dispatchers is a Kotlin object with some properties, each one representing a different type of dispatcher:
- Default: Uses a shared background pool of threads. This one is used if no other dispatcher is specified. It is recommended for CPU-intensive work.
- Main: This dispatcher is confined to the main thread. Operates the UI.
- IO: This one is designed for offloading blocking IO tasks to a shared pool of threads. Used for networking, files read/write, database access, etc.
- Unconfined: It starts the coroutine in the caller thread, but only until the first suspension point. After suspension it resumes the coroutine in the thread that is fully determined by the suspending function that was invoked. The unconfined dispatcher is appropriate for coroutines which neither consume CPU time nor update any shared data (like UI) confined to a specific thread.
Here's how you can specify the dispatcher:
Job
As the name implies, this context element represents a background task or a job. Jobs are cancellable by calling the cancel()
function on it. There are 2 type of Jobs: Job and SupervisorJob.
The difference between these is their cancellation policies:
- Job: If one child coroutine fails, will make the parent fail, causing the sibling coroutines to fail.
If you run this, you'll notice that Completed 2nd coroutine
is never printed out. That's because secondChild
gets cancelled with firstChild
's failure.
- SupervisorJob: If one child coroutine fails, won't make the parent fail and the siblings can continue until they complete.
Now you will see Completed 2nd coroutine
printed because the failure of the first one, didn't cancel the parent coroutine, cancelling the secondChild
coroutine. You may need to open it in the Playground to see the full log.
CoroutineExceptionHandler
This context element will help us catch the exceptions, so we can show a proper error instead of letting the exception propagate. This won't change the cancellation behavior explained in the Job section.
To understand how it works, play with the code below. Switch between Job and SupervisorJob, run it and see the difference on what is printed:
💡 Note: You can combine multiple context elements with the
+
symbol.
CoroutineName
Assign a specific name to a coroutine can be helpful for debugging purposes. This element can help with this. Here's how you can use it:
If you run this code, you will see the name we specified represented:
Combining multiple Coroutine Context Elements
In all cases, the variable type will be CoroutineContext.
🧪 Experiment: Open this code in the playground and create your own combinations
Unveiling the CoroutineScope: Your Gateway to Control
A coroutine scope in Kotlin defines the lifecycle and boundaries within which coroutines can be launched and managed. It ensures structured concurrency by organizing coroutines and managing their lifecycles, allowing for controlled execution and cancellation of tasks.
The coroutine scope must be created on a component with a lifecycle, because it needs to be cancelled when it is no longer needed.
Building and using a CoroutineScope
Using the CoroutineScope() function
The simplest way to build a scope is using the CoroutineScope helper function that accepts a CoroutineContext as the argument:
Using the coroutineScope() suspend function
💡 Note that coroutineScope() is written with lowercase
c
Sometimes we need to access a coroutineScope in a class with no lifecycle like a repository class. In that case you can use the coroutineScope() function:
Then you can use the getUserInfo() function:
The CoroutineScope and its context is inherited from the caller scope. In simple words repository.getUserInfo()
is called from within a CoroutineScope, which. will be the same inside of getUserInfo()
.
Using the MainScope function
Another way to build a coroutine scope is using the MainScope function. If you take a look at the source code, it basically creates a new CoroutineScope with the SupervisorJob and the Main dispatcher as the context.
It is useful to easily build a coroutine scope suitable for UI operations.
Using the lifecycleScope and viewModelScope in Android
In Android, we have coroutine scope extensions and helper functions on some components like viewModels and the view (Activities, Fragments, Composables).
In viewModels you can easily access the viewModelScope. All coroutines launched by it will be cancelled when the viewModel is cleared.
When using the view-system we have the lifecycleScope available for us to use it when needed. It is cancelled when the view is destroyed:
In Compose, we have some side-effects. The first one is the composition-aware rememberCoroutineScope(). The coroutines launched with this scope will be cancelled once the composable leaves the composition:
Another way to launch a coroutine in compose is using the LaunchedEffect. It is cancelled when it leaves the composition. If any of its keys change it is cancelled and restarted:
Cancelling a CoroutineScope
All coroutines are launched by a CoroutineScope and are tied to it. If you cancel a CoroutineScope, all the coroutines launched by it, will be cancelled as well:
🧪 Experiment: Comment out
cancel()
and run it again.
Coroutine-builders: Creating Coroutines
Now that we know what is the coroutine context and how the coroutine scope works, it is time to discover some interesting use cases of launching coroutines.
Coroutine builders
There are multiple ways to create or launch a new coroutine. So far we have been using the launch {}
builder. Let's dive a little more on coroutine builders.
The launch builder
The launch coroutine builder is also known as the fire-and-forget
builder. Because it is used for operations that we don't need to get a value from. These are some sample usages:
- Saving a value in the database
- Writing to a file
- Logging analytics
- etc
In the previous examples you have seen how it is used. But let's analyze its arguments:
- context: The coroutine context. By default it provides an empty coroutine context.
- start: The CoroutineStart option. It provides CoroutineStart.Default if no other is provided. Default means eagerly start.
- block: The lambda block representing the coroutine scope.
It returns a Job, which you can use to cancel the coroutine.
The async builder
If you need to get some value back from the coroutine execution and we need to perform 2 or more tasks in parallel, then we need to use the async coroutine builder. Its arguments are the same as the launch builder.
The operation above takes ~1s. Which is the longer time among all the async coroutines.
As you will notice await()
is a suspend function and you may be wondering, how it is taking ~1s instead of ~1.5s since when the first await()
is called it will suspend the execution until finished.
The reason is that as specified above, the default CoroutineStart value, immediately schedules coroutine for execution. So as soon as the async
block is created, it will start running, instead of waiting for await()
to be invoked.
If you want to change this, you can do so by sending a different CoroutineStart argument. You can confirm the execution time with the measureTime
function:
🧪 Experiment: Remove the start argument, run it again and check the result.
WithContext
Above I specified that we need to use async
only if there are 2 or more operations to be performed. If we need to invoke a suspend function in a specific CoroutineContext we can use withContext
:
💡
launch
andasync
are the most popular coroutine builders. But there are more, do some research and mention them in the comments.
Launching Coroutines: Real-world Applications
Now that we are already experts in the kotlin coroutine library 🤓, let's see some use cases:
I. Performing multiple heavy tasks sequentially
As we mentioned before, suspend functions are sequential by default. Also, we can call normal functions in our coroutine, which will execute sequentially:
II. Concurrent fire-and-forget operations
We learned about the launch coroutine builder and its purpose. This is how you can execute 2 heavy tasks in parallel when you don't need a return value:
II. Concurrent operations with a return value
We already talked about the async
coroutine builder and its preferred use case. Here's another sample:
III. Performing operations concurrently after some other operation
This one is a mix of the previous two cases. As you may be thinking, we just need to call a function (suspend or not) and then launch multiple coroutines to handle the concurrency:
IV. Cancelling a Coroutine
Some times we need to cancel a coroutine under some circumstances. To do so, you just need the job and invoke cancel()
on it.
V. Cancelling a CoroutineScope
As stated before, coroutine scopes can be cancelled. When it happens, all the coroutines launched by that coroutine scope will be cancelled as well:
VI Convert a callback based function a suspend function
When using coroutines we want to get rid of callback altogether. For this we can use the suspendCoroutine
and suspendCancellableCoroutine
helper functions for this:
The difference with suspendCancellableCoroutine
is that it provides a CancellableContinuation
. That allows to call resumeWithException
. That will cancel the current coroutine with the specified exception.
Wrapping Up
Kotlin coroutines are a great way to perform background operations in a structured fashion and with an intuitive set of rules that makes it very customizable and will help you anticipate the result of your operations.
Also, it gets rid of the ugly callbacks that makes it very hard when it comes to execute operations concurrently and even sequentially.
If you want me to cover some specific use cases, just let me know in the comments section.