As you may know about coroutine previously, coroutine is a structured concurrency, which means it can control flow to ensure that every concurrent tasks work well until it completed or being canceled. Because of that, each coroutine have it own lifecycle and scope, represent by CoroutineContext and CoroutineScope.
Coroutine context vs Coroutine scope
Before we dive in coroutine lifecycle, we should know the difference between coroutine context
and coroutine scope
, so that we don't misunderstand them.
Reference from Kotlin - doc
Coroutine scope
is responsible for the structure and parent-child relationships between different coroutines. New coroutines usually need to be started inside a scope.
- FYI: coroutine builders like
launch
orasync
all extend from CoroutineScope. Which means you cannot run a coroutine without CoroutineScope.
Coroutine context
stores additional technical information used to run a given coroutine, like the coroutine custom name, or the dispatcher specifying the threads the coroutine should be scheduled on.
The difference is what they are invented for. Coroutine scope
invented purpose was to the control scope of a coroutine, and how a new coroutine can be launched. On the other hand, Coroutine context
is to provide elements that are responsible for threading.
Checking the CoroutineContext
code, we hard to find any clue that could result in a life-cycle awareness. But, maybe it child does.
Coroutine life-cycle
As you may know when we create a coroutine through coroutine builder, we got an instance of Job
. Job is a cancellable thing with a life-cycle that culminates in its completion. In the end coroutine life-cycle is Job life-cycle, and Job life-cycle specified by its states.
Under the hood, Job is a
coroutine context
when it implementCoroutineContext.Element
(an interface of CoroutineContext)
Job states
Job state is a combination of three variables isActive
, isCompleted
, and isCancelled
. You can see all avaiable states of a Job in the below table:
State | isActive | isCompleted | isCancelled |
---|---|---|---|
New (optional initial state) | false | false | false |
Active (default initial state) | true | false | false |
Completing (transient state) | true | false | false |
Cancelling (transient state) | false | false | true |
Cancelled (final state) | false | true | true |
Completed (final state) | false | true | false |
- isActive: A Job is in an active state when it is created or started. However, coroutine builders that accept a parameter
start
can create a Job with false onisActive
(new state) and later on be activated by callingstart
orjoin
function. - isCancelled: A failure of an active Job with an exception makes it cancelling. Or you can cancel a Job at any time with
cancel
function that forces it to transition to the cancelling state immediately. A job can only archive cancelled state when it finished executing its work and all its children are completed. - isCompleted: By calling
CompletableJob.complete
we transitions the Job to the completing state. It waits in the completing state until all its children completed before transitioning to the completed state.- Note that completing state is purely internal to the job. For an outside observer a completing job is still active, while internally it is waiting for its children.
The state machine of Job will look something like this:
Cancellation of a coroutine
While completed state is quite straightforward to understand, canceling a coroutine may be hard for you. So how can we cancel a coroutine when it's in the middle of the execution?
Cancellation is cooperative
Due to Kotlin-doc : Coroutine cancellation is cooperative. A coroutine code has to cooperate to be cancellable. Any suspend function can be canceled, but if a coroutine is working in a computation and does not check for the cancellation, then it cannot be canceled until its finish work.
Try to run below example in playground
val job = launch {
repeat(1000) { i ->
println("job: I'm sleeping $i")
}
}
delay(1300L) // delay a bit
println("main: I'm tired of waiting!")
job.cancelAndJoin() // cancels the job
println("main: Now I can quit.")
----------- Output -----------
job: I'm sleeping 0
job: I'm sleeping 1
job: I'm sleeping 2
job: I'm sleeping 3
job: I'm sleeping 4
job: I'm sleeping 5
...
An endless job: I'm sleeping...
is printed without any sight of cancellation happening. This proves that a coroutine cannot be canceled when it is in the middle of an execution. How can our coroutine be cancellable?
Making coroutine pleasure to cancel
Conceptually, we have two ways to cancel our coroutine with pleasure:
- Using
suspendCancellableCoroutine
function likedelay
oryield
. Whenever we need to ensure our coroutine is active before starting a time-consuming computation. - Second, we could manually check for the coroutine state in the middle of a Job through the
isActive
variable.
Take a look at this snippet code block for the second approach:
import kotlinx.coroutines.*
fun main() = runBlocking {
val startTime = System.currentTimeMillis()
val job = launch(Dispatchers.Default) {
var nextPrintTime = startTime
var i = 0
while (isActive) {
// print a message twice a second
if (System.currentTimeMillis() >= nextPrintTime) {
println("job: I'm sleeping ${i++} ...")
nextPrintTime += 500L
}
}
}
delay(1300L) // delay a bit
println("main: I'm tired of waiting!")
job.cancelAndJoin() // cancels the job and waits for its completion
println("main: Now I can quit.")
}
----------- Output -----------
job: I'm sleeping 0 ...
job: I'm sleeping 1 ...
job: I'm sleeping 2 ...
main: I'm tired of waiting!
main: Now I can quit.
Avoiding wasting computation on a redundant Job by using
isActive
variable.
Summarize
In this article, we know the difference between CoroutineScope and CoroutineContext. From the purpose of CoroutineContext, we know that the coroutine life-cycle is the Job life-cycle. And Job life-cycle is specified by the combination of isActive
, isCompleted
, and isCancelled
variables. How coroutine cancellation work and ways to apply it in code.