1. Concept of Kotlin Coroutines

Kotlin Coroutines is a well-designed thread framework that uses threads at the underlying level. Coroutines can be understood as tasks executed on threads, and these tasks can switch between different threads. A single thread can run multiple coroutines, and coroutines can run on different threads. In Android development, we can run time-consuming tasks such as network requests and database operations on the IO thread, while updating the UI runs on the main thread.

From a developer’s perspective, Kotlin Coroutines allow us to write asynchronous code in a synchronous manner, solving the callback hell problem in traditional thread switching. Suspending operations in coroutines do not block threads and have minimal additional performance overhead.

2. Ways to Create Coroutines

To use coroutines in Android Studio, we need to import two coroutine support libraries:

1
2
3
4
// Coroutine core library
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.4"
// Coroutine Android support library
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.4"

In Kotlin, there are several ways to create coroutines:

2.1 runBlocking

runBlocking is a top-level function that starts a new coroutine and blocks the current thread until the code inside the coroutine block is executed. It returns a generic type T.

1
2
3
runBlocking {
// Coroutine block
}

2.2 CoroutineScope.launch

We can start a coroutine using the launch extension method of CoroutineScope. It does not block the current thread and returns a Job.

1
2
3
CoroutineScope.launch {
// Coroutine block
}

2.3 CoroutineScope.async

We can start a coroutine using the async extension method of CoroutineScope. It does not block the current thread and returns a Deferred.

1
2
3
CoroutineScope.async {
// Coroutine block
}

In general, we commonly use the second and third ways to start coroutines. The first way runBlocking blocks the current thread and is typically used for debugging.

It is worth noting that the last line of code in the async function body will be returned as the result, which is the generic type T of Deferred. We can use other coroutine functions to retrieve this execution result.

Example code:

1
2
3
4
5
6
7
GlobalScope.launch {
// Coroutine block
}

GlobalScope.async {
// Coroutine block
}

3. Coroutine Scope and Coroutine Context

When creating coroutines, we need to use a coroutine scope (CoroutineScope) to start the coroutines.

3.1 What is a Coroutine Scope?

A coroutine scope CoroutineScope represents the range of a coroutine’s execution. It is an interface with a single property coroutineContext, which represents the coroutine’s context. We can understand CoroutineScope as the encapsulation of the coroutine context.

1
2
3
interface CoroutineScope {
val coroutineContext: CoroutineContext
}

3.2 What is a Coroutine Context?

A coroutine context CoroutineContext represents the context information of a coroutine. Before understanding the coroutine context, let’s first understand the concept of a context. In Android development, our Application, Activity, etc., are defined as contexts. A context can be understood as a container that holds the configuration information required for code execution.

The coroutine context CoroutineContext represents the configuration information required to start a coroutine. CoroutineContext is an interface that contains a series of subclasses, which together constitute the CoroutineContext of a coroutine scope.

CoroutineContext provides some operation methods, such as get (to get a specific CoroutineContext based on a key), plus (to merge two CoroutineContexts), minusKey (to remove a CoroutineContext with a specific key), etc.

Example code:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
interface CoroutineContext {
operator fun <E : Element> get(key: Key<E>): E?
fun <R> fold(initial: R, operation: (R, Element) -> R): R
operator fun plus(context: CoroutineContext): CoroutineContext
fun minusKey(key: Key<*>): CoroutineContext

interface Key<E : Element>
interface Element : CoroutineContext {
val key: Key<*>
operator fun <E : Element> get(key: Key<E>): E?
fun <R> fold(initial: R, operation: (R, Element) -> R): R = operation(initial, this)
fun minusKey(key: Key<*>): CoroutineContext =
if (this.key == key) EmptyCoroutineContext else this
}
}

3.3 Coroutine Scope and Subcoroutines

We can start another coroutine inside a coroutine, making it a child coroutine. Two important points to note are:

  1. The coroutine scope of the child coroutine inherits the coroutine context from the parent coroutine’s coroutine scope.
  2. If the parent coroutine is cancelled, all its child coroutines will also be cancelled.

We will verify these two points in the example code below.

3.4 Subclasses of CoroutineContext

CoroutineContext has many subclasses, each with a different purpose, collectively forming the CoroutineContext of a coroutine scope.

3.4.1 CoroutineDispatcher

Coroutine dispatcher CoroutineDispatcher determines on which thread or threads the related coroutines will be executed. It can be called the thread scheduler for coroutines.

1
2
3
4
5
6
abstract class CoroutineDispatcher : AbstractCoroutineContextElement(ContinuationInterceptor), ContinuationInterceptor {
companion object Key : AbstractCoroutineContextKey<ContinuationInterceptor, CoroutineDispatcher>(
ContinuationInterceptor,
{ it as? CoroutineDispatcher }
)
}

Kotlin provides four dispatchers:

  • Default: Default dispatcher, suitable for pure computation tasks or short-duration tasks. It is a dispatcher for CPU-intensive tasks.
  • Main: UI dispatcher, only meaningful on UI programming platforms. It is used for updating the UI, such as the main thread in Android.
  • IO: IO dispatcher, suitable for executing IO-related operations such as network requests, database operations, file operations, etc.
  • Unconfined: Unconfined dispatcher, coroutines under this dispatcher can run on any thread.

We can obtain these dispatchers through the Dispatchers object.

Example code:

1
2
3
4
5
6
7
8
9
10
11
GlobalScope.launch(Dispatchers.Default) {
// Coroutine executed on the default dispatcher
}

GlobalScope.launch(Dispatchers.Main) {
// Coroutine executed on the main thread
}

GlobalScope.launch(Dispatchers.IO) {
// Coroutine executed on the IO thread
}

It is worth noting that after coroutine creation, we can override the coroutine context in the coroutine scope by passing the desired coroutine context as the first parameter to the CoroutineScope functions. This allows us to change the dispatcher of the coroutine.

Example code:

1
2
3
4
5
6
7
GlobalScope.launch(Dispatchers.Main) {
// Coroutine executed on the main thread

withContext(Dispatchers.IO) {
// Code block executed on the IO thread
}
}

3.4.2 CoroutineName

Coroutine name CoroutineName can be used to name a coroutine, making it easier to distinguish coroutines.

1
2
3
4
data class CoroutineName(val name: String) : AbstractCoroutineContextElement(CoroutineName) {
companion object Key : CoroutineContext.Key<CoroutineName>
override fun toString(): String = "CoroutineName($name)"
}

Example code:

1
2
3
GlobalScope.launch(Dispatchers.Main + CoroutineName("MainCoroutine")) {
// Main coroutine with a name
}

It is worth noting that child coroutines inherit the coroutine context from their parent coroutine’s coroutine scope. If a child coroutine sets its own context, the context set by the child coroutine will override the inherited context from the parent coroutine.

3.4.3 Job and Coroutine Lifecycles

After a coroutine is started, we can obtain a Job object, which allows us to monitor the lifecycle of the coroutine and perform operations on it, such as cancelling the coroutine.

Job can be understood as the coroutine itself. The lifecycle of a coroutine is as follows:

  • New: The coroutine is in the new state after creation.
  • Active: The coroutine enters the active state after being started.
  • Completed: The coroutine and all its child coroutines enter the completed state after completing their tasks.
  • Cancelled: The coroutine enters the cancelled state after being cancelled.

We can use the fields of the Job object to check the state of the coroutine:

  • isActive: Check if the coroutine is in the active state.
  • isCancelled: Check if the coroutine is cancelled.
  • isCompleted: Check if the coroutine has completed.

In addition to getting the coroutine state, there are many functions available to operate on the coroutine, such as cancel() to cancel the coroutine, start() to start the coroutine, await() to wait for the coroutine to finish executing, etc.

It is worth noting that if a parent coroutine is cancelled, all its child coroutines will also be cancelled.

3.4.4 CoroutineExceptionHandler

The coroutine exception handler CoroutineExceptionHandler is used to catch exceptions in coroutines. Exceptions that occur in coroutines will be caught, and the handleException method of CoroutineExceptionHandler will return the exception for us to handle.

1
2
3
4
interface CoroutineExceptionHandler : CoroutineContext.Element {
companion object Key : CoroutineContext.Key<CoroutineExceptionHandler>
fun handleException(context: CoroutineContext, exception: Throwable)
}

The handleException method returns two parameters: the coroutine in which the exception occurred and the exception itself.

Example code:

1
2
3
4
5
6
7
val exceptionHandler = CoroutineExceptionHandler { coroutineContext, throwable ->
Log.e("Caught Exception", "${coroutineContext[CoroutineName]}: $throwable")
}

GlobalScope.launch(Dispatchers.Main + CoroutineName("MainCoroutine") + exceptionHandler) {
throw NullPointerException()
}

3.5 Summary of Coroutine Scope and Coroutine Context

  • CoroutineScope represents the execution range of a coroutine and encapsulates the coroutine context.
  • CoroutineContext represents the context information of a coroutine, which includes multiple subclasses that together form the CoroutineContext of a coroutine scope.
  • Child coroutines inherit the coroutine context from their parent coroutine’s coroutine scope.
  • CoroutineDispatcher determines on which thread or threads the coroutine will be executed.
  • CoroutineName can be used to name a coroutine, making it easier to distinguish coroutines.
  • Job represents the lifecycle of a coroutine, allowing us to monitor its state and perform operations on it.
  • CoroutineExceptionHandler is used to catch exceptions in coroutines.

4. Coroutine Start Modes

When creating a coroutine, we can specify a start mode CoroutineStart.

  • DEFAULT: Default start mode, the coroutine is scheduled immediately after creation, but it may be cancelled before execution.
  • LAZY: Lazy start mode, the coroutine is not scheduled immediately after creation. It will only be scheduled when we need it to execute. We need to manually call start, join, await, etc., on the Job object to trigger scheduling.
  • ATOMIC: The coroutine is scheduled immediately after creation, but unlike the DEFAULT mode, it does not respond to cancellation until the first suspension point is encountered.
  • UNDISPATCHED: The coroutine is executed directly on the current thread until the first suspension point is encountered. Similar to the ATOMIC mode, but UNDISPATCHED is affected by the dispatcher.

5. Suspended Functions

Suspended functions are functions that are marked with the suspend keyword and are used to implement suspension and resumption operations. Suspended functions can only be called within coroutines or other suspended functions.

The characteristic of suspended functions is “suspension and resumption”. When a coroutine encounters a suspended function, the coroutine is suspended, and when the suspended function finishes executing, the coroutine resumes from where it was suspended. Suspension is non-blocking and does not block the thread, and resumption does not require manual operation as the coroutine will automatically resume.

5.1 Sequential Execution of Asynchronous Code

Example code 1:

We define two suspended functions, one with a delay of 1 second and the other with a delay of 2 seconds (simulating network requests). Then we add the results of the two functions together. Without using coroutines, we would need to use a callback-like approach to obtain the results due to the different delay times. However, with coroutines, as can be seen from the printed results, the code is executed sequentially. We can use the measureTimeMillis method to measure the execution time, which is approximately 3 seconds.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
suspend fun returnNumber1(): Int {
delay(1000L)
return 1
}

suspend fun returnNumber2(): Int {
delay(2000L)
return 2
}

GlobalScope.launch {
val time = measureTimeMillis {
val number1 = returnNumber1()
val number2 = returnNumber2()
val result = number1 + number2
}
}

5.2 Implementing Concurrency with async

We modify the previous code by putting the two suspended functions in separate child coroutines and using async to obtain the final result. async function returns a Deferred, and we can use the await method to wait for the child coroutines to finish executing and obtain the results. Since the launch function does not return a value, we cannot directly obtain the result.

1
2
3
4
5
6
7
8
9
10
11
GlobalScope.launch(Dispatchers.Main) {
val time = measureTimeMillis {
val deferred1 = async {
returnNumber1()
}
val deferred2 = async {
returnNumber2()
}
val result = deferred1.await() + deferred2.await()
}
}

In this example, we use async to start two child coroutines, both of which contain suspended functions, so they will be suspended. However, the parent coroutine does not get suspended before calling the await() suspended function, so it can run normally. The two child coroutines are executed concurrently, and the overall execution time is about 2 seconds.