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 | // Coroutine core library |
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 | runBlocking { |
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 | CoroutineScope.launch { |
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 | CoroutineScope.async { |
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 | GlobalScope.launch { |
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 | interface CoroutineScope { |
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 CoroutineContext
s), minusKey
(to remove a CoroutineContext
with a specific key), etc.
Example code:
1 | interface CoroutineContext { |
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:
- The coroutine scope of the child coroutine inherits the coroutine context from the parent coroutine’s coroutine scope.
- 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 | abstract class CoroutineDispatcher : AbstractCoroutineContextElement(ContinuationInterceptor), ContinuationInterceptor { |
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 | GlobalScope.launch(Dispatchers.Default) { |
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 | GlobalScope.launch(Dispatchers.Main) { |
3.4.2 CoroutineName
Coroutine name CoroutineName
can be used to name a coroutine, making it easier to distinguish coroutines.
1 | data class CoroutineName(val name: String) : AbstractCoroutineContextElement(CoroutineName) { |
Example code:
1 | GlobalScope.launch(Dispatchers.Main + CoroutineName("MainCoroutine")) { |
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 | interface CoroutineExceptionHandler : CoroutineContext.Element { |
The handleException
method returns two parameters: the coroutine in which the exception occurred and the exception itself.
Example code:
1 | val exceptionHandler = CoroutineExceptionHandler { coroutineContext, throwable -> |
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 theCoroutineContext
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 callstart
,join
,await
, etc., on theJob
object to trigger scheduling.ATOMIC
: The coroutine is scheduled immediately after creation, but unlike theDEFAULT
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 theATOMIC
mode, butUNDISPATCHED
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 | suspend fun returnNumber1(): Int { |
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 | GlobalScope.launch(Dispatchers.Main) { |
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.