Kotlin Coroutine Essentials: Everything you need to know
This is my second article in the Coroutines series, The following is the list of articles you can check to understand Coroutines thoroughly.
Coroutine Essentials(this.article).
I expect you to know What coroutines are, and where you are using them in your application. If you are not familiar yet, read this.
First, we will start with building our coroutines using some well-known Coroutine builders:
Launch:
The launch
coroutine builder is used to start a new coroutine that does not return a result. It is for the fire-and-forget tasks, where you have to just call some long-running functions and do not care about what they return.
fun main() {
println("Start")
// Launch a coroutine
GlobalScope.launch {
delay(1000) // Simulate some background work
println("Coroutine completed")
}
println("End")
}
//Start
//End
//...
//Coroutine completed
Async:
The async
coroutine builder is used to start a coroutine that returns a Deferred
value. We can call the suspending function await
on the deferred value to wait and get the result.
fun main() {
println("Start")
val deferredResult = GlobalScope.async {
delay(1000) // Simulate some background work
"Coroutine completed"
}
// Do some other work in the meantime
// Retrieve the result from the deferred value
runBlocking {
val result = deferredResult.await()//await is a suspend function
println(result)
}
println("End")
}
//Start
//Coroutine completed
//End
CoroutineScope:
Consider this as the Mother of Coroutines. A CoroutineScope
keeps track of any coroutine you are creating, just like a Mother takes care of her Children.
The ongoing work (running coroutines) can be canceled by calling scope.cancel()
at any point in time.
You should create a CoroutineScope
whenever you want to start and control the lifecycle of coroutines in a particular layer of your app, like in Android, there are viewModelScope
and lifecycleScope
or GlobalScope for the whole application lifecycle.
When creating CoroutineScope
it takes a CoroutineContext
as a parameter to its constructor. You can create a new scope & coroutine with the following code:
// Job and Dispatcher are combined into a CoroutineContext which
// will be discussed shortly
val scope = CoroutineScope(Job() + Dispatchers.Main)
val job = scope.launch {
// new coroutine
}
Job:
A Job instance in the coroutineContext represents the coroutine itself. A Job
is a handle to a coroutine. For every coroutine that you create (by launch
or async
), it returns a Job
instance that uniquely identifies the coroutine and manages its lifecycle.
A Job
can go through a set of states: New, Active, Completing, Completed, Cancelling, and Cancelled. While we don’t have access to the states themselves, we can access properties of a Job: isActive
, isCancelled
and isCompleted
.
States of a Job/Coroutine:
Types of Jobs:
Job: It represents a single coroutine and provides control over its lifecycle, such as starting, waiting, and canceling. It can be used for simple asynchronous tasks.
DeferredJob: It represents a coroutine that produces a result of type
T
and provides a way to await the result using theawait
function.Deferred
is used when you need to perform computations concurrently and asynchronously and obtain a result when it's ready.SupervisorJob: A job type that is used as a parent job for child coroutines. Unlike regular jobs, a failure or cancellation of a child's coroutine does not propagate to its parent and other siblings. It’s useful when you want to isolate failures in a specific branch of coroutines.
CompletableJob: A type of job that can be completed explicitly using the
complete()
function. It's often used in custom implementations or when you want to create custom ways of handling the job's lifecycle.
CoroutineContext:
A CoroutineContext
is an interface that represents a context of execution for coroutines. It provides a set of elements that define the behavior of a coroutine. This context is crucial for managing coroutine execution, including handling concurrency, thread pooling, and scheduling.
It’s made of:
Job
— controls the lifecycle of the coroutine.CoroutineDispatcher
— dispatches work to the appropriate thread.CoroutineName
— name of the coroutine, useful for debugging.CoroutineExceptionHandler
— handles uncaught exceptions
A CoroutineContext for a Coroutine = Its new Job + Other things inherited from parent(If any).
Since a CoroutineScope
can create coroutines and you can create more coroutines inside a coroutine, an implicit task hierarchy is created. In the following code snippet, apart from creating a new coroutine using the CoroutineScope
, see how you can create more coroutines inside a coroutine:
val scope = CoroutineScope(Job() + Dispatchers.Main)
val job = scope.launch {
// New coroutine that has CoroutineScope as a parent
val result = async {
// New coroutine that has the coroutine started by
// launch as a parent
}.await()
}
The root of that hierarchy is usually the CoroutineScope
. We could visualize that hierarchy as follows:
Coroutines are executed in a task hierarchy. The parent can be either a CoroutineScope or another Coroutine.
Parent CoroutineContext:
In the task hierarchy, each coroutine has a parent that can be either a CoroutineScope
or another coroutine. However, the result CoroutineContext
of a coroutine can be different from the CoroutineContext
of the parent since it’s calculated based on this formula:
Parent context = Defaults + inherited
CoroutineContext
+ arguments
Where:
Some elements have default values:
Dispatchers.Default
is the default ofCoroutineDispatcher
and“coroutine”
the default ofCoroutineName
.The inherited
CoroutineContext
is theCoroutineContext
of the coroutine that created it.Arguments passed in the coroutine builder will take precedence over those elements in the inherited context.(Examples below)
Note: CoroutineContext
s can be combined using the +
operator. As the CoroutineContext
is a set of elements, a new CoroutineContext
will be created with the elements on the right side of the plus overriding those on the left.
E.g. (Dispatchers.Main, “name”) + (Dispatchers.IO) = (Dispatchers.IO, “name”)
Every coroutine started by this CoroutineScope will have at least those elements in the CoroutineContext. CoroutineName is gray because it comes from the default values.
Now that we know what’s the parent CoroutineContext
of a new coroutine, it's actual CoroutineContext
will be:
New coroutine context = parent
CoroutineContext
+Job()
If with the CoroutineScope
shown in the image above we create a new coroutine like this:
val job = scope.launch(Dispatchers.IO) {
// new coroutine
}
What’s the parent CoroutineContext
of that coroutine and its actual CoroutineContext
? See the solution in the image below!
The Job in the CoroutineContext and the parent context will never be the same instance as a new Coroutine always gets a new instance of a Job(New Job is Green and the parent has a red one).
The resulting parent CoroutineContext
has Dispatchers.IO
instead of the scope’s CoroutineDispatcher
since the argument of the coroutine builder overrides it.
I hope you understand all the important concepts related to coroutines, and now you can efficiently utilize them to make great applications. Make sure to Follow me for exciting future content.
Subscribe to my newsletter
Read articles from Sagar Malhotra directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by