Coroutines have been around for a while now and there are so many different articles around it. But I found that there is a steep learning curve to it so it did take me a while to really understand the fundamentals of what Coroutines are and how it works. So I thought I would share some of my learnings as I understood them.
Coroutine stands for cooperating functions. They provide a more efficient and readable way to handle asynchronous tasks. It is similar to a thread, in that it takes a block of code to run that works concurrently with the rest of the code. However, a coroutine is not bound to any particular thread. It may suspend its execution in one thread and resume in another one. Coroutines were launched to Kotlin in version 1.3.
- Coroutines are lightweight — We can run many coroutines on a single thread due to its support for suspension. Here suspension means that you can execute some instructions, then stop the coroutine in between the execution and continue when you wish to. Suspending saves memory over blocking while supporting many concurrent operations.
- Coroutines have fewer memory leaks — coroutines follow the structured concurrency principle, which means that each coroutine should be launched inside a specific context with a determinate life-time. Structured concurrency is an approach where the lifetime of coroutines is tied to a specific scope, ensuring that all launched coroutines within that scope complete before the scope itself completes. This helps avoid coroutine leaks and simplifies resource management.
- On android, coroutines offer Main-safety — coroutines help to manage long-running tasks that might otherwise block the main thread and cause your app to become unresponsive. Main-safety allows you to ensure that any suspend function can be called from the main thread.
- Coroutines offer built-in cancellation support — one of the most important mechanisms of coroutines is cancellation because, on Android, nearly every coroutine is associated with some view, and if this view is destroyed, its coroutines are not needed, so they should be cancelled. This is a crucial capability that used to require a lot of effort from developers, but coroutines offer a simple and safe cancellation mechanism.
- Coroutines are cooperatively multitasked — this means that coroutines are concurrency primitives that cooperatively execute a set of instructions and that the operating system doesn’t control the scheduling of tasks or processes performed by coroutines. Instead, it relies on the program, and platform that runs them to do that. As such, coroutines can yield control back to the scheduler to allow other threads to run. Scheduler from OS is responsible to let these threads do their job and if need be can also pause them so that the same resources can be used by other threads.
These are some common keywords you will come across when learning about coroutines.
suspend
functions- Coroutine Scope (includes Dispatchers, Job)
- Coroutine builders
- Coroutine Context
`Suspend` functions
Suspending functions are those functions that can be paused and then continued later. A suspend function indicates that the function can be suspended, allowing other coroutines to run while it’s waiting for a non-blocking operation to complete. While the suspend function is executing, the coroutine releases the thread on which it was running and allows other coroutines to access that thread (because coroutines are cooperative).
The syntax of the suspended function is the same as that of the normal function but with the addition of the `suspend` keyword. Suspend functions are only allowed to be called from a coroutine or another suspend function.
suspend fun doSomething(): Int {
delay(1000L) // pretend we are doing something useful here
return 13
}
Coroutine scope
A coroutine scope defines the lifecycle/lifetime of a coroutine. It is responsible for controlling the lifecycle of a group of coroutines and their context. A CoroutineScope
keeps track of all coroutines it creates. Therefore, if you cancel a scope, you cancel all coroutines it created. When a child coroutine is started inside a parent one it inherits the parent scope (unless specified otherwise) so that when parent coroutine is stopped, so will the child coroutine.
In Android, coroutines have three basic scopes:
- Global Scope:
GlobalScope
is a predefined coroutine scope that lasts for the entire application lifetime. While it can be convenient, it’s generally recommended to use custom coroutine scopes to ensure structured concurrency.
GlobalScope.launch {
val config = fetchConfigFromServer() // network request
updateConfiguration(config)
}
- LifeCycle Scope: bound to the lifetime of a
LifecycleOwner
(Fragment
Activity
). When theFragment
Activity
is destroyed, the coroutines in this scope will also cancel. UsingLifecycleScope
we can also use special launch condition:
*launchWhenCreated
will launch coroutine if the lifecycle is at least on create state and gets suspended if it’s on destroy state.
*launchWhenStarted
will launch coroutine if the lifecycle is at least on start state and gets suspended if it’s on stop state.
*launchWhenResumed
will launch coroutine if the lifecycle is at least on resume state and gets suspended if it’s on pause state.
lifecycleScope.launchWhenResumed {
println("loading..")
delay(3000)
println("job is done")
}
- ViewModel Scope: bound to the lifetime of a
ViewModel
. When theViewModel
clears, the coroutines in this scope will also cancel.
viewModelScope.launch {
println("loading..")
delay(3000)
println("job is done")
}
Coroutine builders
Coroutine builders are functions for initialising or creating new coroutines. They provide a convenient way to initiate and control the execution of coroutines.
launch
: launches a new coroutine concurrently, i.e., without blocking the current thread. It automatically gets canceled when the resulting job is canceled, and it doesn’t return any result. The return type oflaunch
is aJob
. That means you can control the coroutine lifecycle by interacting with thatJob
. You can easily cancel it by callingjob.cancel()
.launch
is used a lot on ViewModels to create a bridge from non-suspending code to suspending code.
launch {
delay(1000L)
println("Hello World!")
}
runBlocking
: runs a new coroutine and blocks the current thread until its completion. In other words, the thread that runs in it gets blocked for the given duration until all the code blocks inside the brackets of run-blocking complete their execution.
fun main() = runBlocking { // this: CoroutineScope
doWorld()
}suspend fun doWorld() {
delay(1000L)
println("Hello Kotlin!")
}
async
: like the launch function, it is also used to launch a new coroutine; the only difference is that it returns a deferred instead of a Job. The deferred is a non-blocking future that promises to deliver the result later. The running coroutine is cancelled when the resulting deferred is cancelled. The async builder allows you to get the returned value by callingawait
.
fun main() = runBlocking<Unit> {val time = measureTimeMillis {
val one = async { doSomethingUsefulOne() }
val two = async { doSomethingUsefulTwo() }
println("The answer is ${one.await() + two.await()}")
}
println("Completed in $time ms")
}
delay
: is a suspending function that is used to suspend a function for a particular time and resumes it after that time. It doesn’t block the underlying thread but allows the other coroutines to run and use the underlying thread.
launch {
delay(2000L)
println("World 2")
}
withContext
: It is a suspending function that is used to switch coroutine context within a coroutine. It suspends the current coroutine, switches to the specified context, and resumes execution in the new context. You’ll be using it most of the time to switch the dispatcher the coroutine will be executed on. BecausewithContext
lets you control what thread any line of code executes on without introducing a callback to return the result, you can apply it to very small functions like reading from your database or performing a network request.
fun main() = runBlocking {
val data = withContext(Dispatchers.IO) {
fetchData()
}
println("Response = $data")
}suspend fun fetchData(): String {
return "Hello world!"
}
Coroutine context
Coroutine Context is a set of elements that define the behaviour and characteristics of a coroutine. It includes things like dispatchers, jobs, exception handlers, and coroutine name. The context is used to determine how and where the coroutine will be executed.
Dispatcher
A Coroutine Dispatcher
is responsible for determining the thread or threads on which the coroutine will be executed. Dispatchers have 4 types:
- Main Dispatchers — Main dispatchers execute the coroutine on the main thread. The main dispatchers do most of the work on UI.
- IO Dispatchers — IO Dispatchers initiate the coroutine on the IO thread. This dispatcher uses a shared pool of threads that is created on demand. This one is suitable for I/O operations that can block the execution thread, such as reading or writing files, making database queries, or making network requests.
- Default Dispatchers — The default dispatcher is used when no other dispatcher is explicitly specified in the scope. It takes advantage of a pool of shared background threads. This is a good option for compute-intensive coroutines that need CPU resources.
- Unconfined Dispatcher — Allows a coroutine to run on any thread, even on different threads for each resume. It is appropriate for coroutines that do not consume CPU or update shared data confined to a specific thread.
fun main() = runBlocking {
launch { // context of the parent, main runBlocking coroutine
println("main runBlocking : I'm working in thread ${Thread.currentThread().name}")
}
launch(Dispatchers.Unconfined) { // not confined -- will work with main thread
println("Unconfined : I'm working in thread ${Thread.currentThread().name}")
}
launch(Dispatchers.Default) { // will get dispatched to DefaultDispatcher
println("Default : I'm working in thread ${Thread.currentThread().name}")
}
launch(newSingleThreadContext("MyOwnThread")) { // will get its own new thread
println("newSingleThreadContext: I'm working in thread ${Thread.currentThread().name}")
}
}
You can also execute coroutines in any of your thread pools by converting them to a CoroutineDispatcher
using the Executor.asCoroutineDispatcher()
extension function. Private thread pools can be created using:
newSingleThreadContext()
: Crafts a coroutine execution environment using a dedicated thread with built-in yield support. It’s a delicate API that allocates native resources (the thread itself), requiring careful management.newFixedThreadPoolContext
: Establishes a coroutine execution environment with a fixed-size thread pool, enabling parallel execution of coroutines while managing thread resources carefully.
Coroutine Job
For every coroutine that is created, a Job instance is returned to uniquely identify that coroutine and allow you to manage its lifecycle. Jobs act as a handle to the coroutine in the queue. 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
.
val job = launch { // launch a new coroutine and keep a reference to its Job
delay(1000L)
println("Hello World!")
}
job.join() // wait until child coroutine completes
println("Done")
SupervisorJob — It is an implementation of Job that acts as a supervisor for child coroutines. 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.
fun main() = runBlocking {
val supervisorJob = SupervisorJob()val coroutine1 = launch(supervisorJob) {
println("Coroutine 1")
throw RuntimeException("Error in Coroutine 1")
}
val coroutine2 = launch(supervisorJob) {
println("Coroutine 2")
delay(500)
println("Coroutine 2 completed")
}
coroutine1.join()
coroutine2.join()
println("Parent coroutine: ${supervisorJob.isActive}") // Output: Parent coroutine: true
}
Coroutine cancellations
Cancellation in coroutines is managed by a Job
(a Job is our handle to a coroutine, and it has a lifecycle). We can cancel a coroutine by calling the .cancel()
function on its Job. When launching multiple coroutines, we can rely on cancelling the entire scope coroutines are launched into as this will cancel all of the child coroutines created.
Cancellation is nothing more than throwing a CancellationException
. The critical distinction here is that if a coroutine throws a CancellationException
, it is considered to have been cancelled normally, while any other exception is considered a failure. While suspend functions coming from the coroutines library are safe to cancel, you should always think about cooperating with cancellation when writing your own code.
- One way to make your code cancellable is to explicitly check the current Job’s state. We can use the
isActive()
extension function on both theCoroutineContext
andCoroutineScope
. - Another common way to check for cancellation is to call
ensureActive()
, which is an extension function available for Job,CoroutineContext
, andCoroutineScope
.