Things every Kotlin Developer should know about Coroutines. Part 1: CoroutineContext.
The CoroutineContext
is the backbone of the coroutines library. Every coroutine you launch will have a context. However, given its nature and flexible API, it might prove pretty tricky to use it correctly.
In Part 1 of my series about coroutines, I will take a close look at the CoroutineContext
and discuss the best practices on how to use it.
CoroutineContext Under the Hood
To kick this article off, let's take a look at the declaration of CoroutineContext
:
/**
* Persistent context for the coroutine.
* It is an indexed set of [Element] instances.
* An indexed set is a mix between a set and a map.
* Every element in this set has a unique [Key].
*/
@SinceKotlin("1.3")
public interface CoroutineContext
As we can see from the KDoc, in broad terms, the CoroutineContext
is just a Map
that stores a set of Element
objects that have a unique Key
.
We can access those Element
s using a Key
, just as we would in a normal Map
:
/**
* Returns the element with the given [key] from this context or `null`.
*/
public operator fun <E : Element> get(key: Key<E>): E?
However, notice that the Key
is typed. We will discuss that a bit later.
CoroutineContext Elements
Since CoroutineContext
is nothing more than a container, the most important part is its content.
An Element
is anything a coroutine might need to run correctly. The most common examples would be:
Job
- a cancellable handle to a coroutine with a lifecycle;CoroutineDispatcher
,MainCoroutineDispatcher
- dispatchers for a coroutine to run on;CoroutineId
,CoroutineName
- elements mainly used to debug coroutines;CoroutineExceptionHandler
- an element that handles uncaught exceptions.
There are many more elements defined in the coroutines library, but these are the main ones that you would use in your code.
When it comes to the implementation of Element
s, it is curious how the coroutines library uses typed Key
s.
Here is how we would get a Job
from the coroutine context, using a Key
:
val job = coroutineContext[Job]
As you can see, the Key
is just the name of the Element
we want to access. This is not a syntax you see often, but it is a perfectly valid Kotlin syntax nevertheless.
The Key
for each Element
is just a plain companion object
of that element that implements Key<Element>
interface, and therefore, we can use it as a Key
for the given type.
Here is an example:
public interface Job : CoroutineContext.Element {
/**
* Key for [Job] instance in the coroutine context.
*/
public companion object Key : CoroutineContext.Key<Job>
Modifying a CoroutineContext
A CoroutineContext
behaves like an immutable map - it doesn't have a set
function. Therefore, you can read stored elements, but can't modify them directly:
// we can read the elements stored in a context by their Key
val job = coroutineContext[Job]
// we cannot modify stored elements
coroutineContext[Job] = Job() <- compiler error
That doesn't mean, however, that we cannot modify a CoroutineContext
. For that, it has dedicated functions, such as:
/**
* Returns a context containing elements from this context
* and elements from other [context].
* The elements from this context with the same key
* as in the other one are dropped.
*/
public operator fun plus(context: CoroutineContext): CoroutineContext
/**
* Returns a context containing elements from this context,
* but without an element with the specified [key].
*/
public fun minusKey(key: Key<*>): CoroutineContext
/**
* Accumulates entries of this context starting with[initial] value
* and applying[operation] from left to right to current accumulator value
* and each element of this context.
*/
public fun<R>fold(initial: R, operation:(R, Element)-> R): R
Out of the functions mentioned above, in most cases, you would only use the plus
operator function, which uses the fold
function under the hood.
If you have used coroutines before, the plus
operator should be very familiar to you:
launch(Dispatchers.IO + CoroutineName("TestCoroutine")) {
doStuff()
}
Keep in mind that this plus
is not associative. In other words, context1
+ context2
is not the same as context2
+ context1
since all the keys from the left context will be overwritten by the keys from the right context. Of course, It doesn't matter when combining two distinct Element
s as in the example above, but when combining sets of Element
s, this becomes an important consideration.
And yes, this example also means that every Element
is also a CoroutineContext
to allow for such a syntax:
/**
* An element of the [CoroutineContext]. An element
* of the coroutine context is a singleton context by itself.
*/
public interface Element : CoroutineContext
Each Element
by itself is a valid CoroutineContext
that can be combined with other Element
s and/or CoroutineContext
s.
This also means that we can use the get
syntax on any Element
:
// nonsense, but api allows it
val job = Job()[Job]
That is not something that you would ever use in your code. Still, I find this example somewhat educational to illustrate the point above and the flexibility of coroutines API.
The rest of the CoroutineContext
's public API is realized with the help of extension functions and properties that access some functionality of an Element
.
Here is an example of a property that checks whether or not a CoroutineContext
is active:
public val CoroutineContext.isActive: Boolean
get() = this[Job]?.isActive == true
We will not focus on these extensions for now, since in most cases, it would make sense to use similar functions on a CoroutineScope
, but we will talk about it in Part 2 of this series.
Best Practices and Recommendations
As you have seen, the CoroutineContext
API is very flexible and allows us to add and combine different elements as we see fit.
However, you should almost never do that.
These flexible APIs are one of the reasons for the steep learning curve of the coroutines library. The thing is that most of that flexibility is not meant for the end-user. We will discuss why it is this way in one of the later parts of this series.
It is very easy to mess things up, especially when starting out using coroutines. So, to make things a bit easier, here are some recommendations on using the CoroutineContext
.
First of all, the only elements you should ever consider manually adding to the context when launching a new coroutine are CoroutineDispather
(or MainCoroutineDispatcher
) and CoroutineExceptionHandler
. In some specific debug cases, you can also use CoroutineName
.
Here is an example of changing a dispatcher:
fun main() {
runBlocking {
println("I am running on ${Thread.currentThread().name}")
launch(Dispatchers.IO) {
workOnIO() // this might be blocking
}
}
}
suspend fun workOnIO() {
delay(100)
println("Now I have switched to ${Thread.currentThread().name}")
}
Output:
I am running on main
Now I have switched to DefaultDispatcher-worker-1
Important!
runBlocking
should never be used outside tests and examples like these. In production code, coroutines must have a proper scope. I am using it in this article for illustrational purposes only.
One thing to keep in mind here is that Dispatchers.IO
shares threads with Dispatchers.Default
. Therefore we get this output.
Also notice that when we manage our dispatchers from the top-level coroutine, we might launch a suspending function that will block our thread. For example, if the workOnIO
function instead of the non-blocking delay()
called Thread.sleep()
, or, more realistically, read from a huge file.
A coroutine will wait for a suspending function to finish regardless since the execution of suspending functions is sequential, but if we don't block the thread while waiting for a coroutine to resume, it is free to do other work.
With that in mind, there is one recommendation about dispatchers that comes from Kotlin's project lead Roman Elizarov, who was the lead designer of coroutines library before that.
Here is an important excerpt:
Suspending functions add a new dimension to code design. It was blocking/non-blocking without coroutines and now there is also suspending/non-suspending on top of that. To make everybody’s life simpler we use the following convention: suspending functions do not block the caller thread. The means to implement this convention are provided by the
withContext
function.
With that convention in mind, here is how we could improve that code:
fun main() {
runBlocking {
launch {
println("I am running on ${Thread.currentThread().name}")
workOnIO()
}
}
}
suspend fun workOnIO() {
withContext(Dispatchers.IO) {
delay(100)
println("Now I have switched to ${Thread.currentThread().name}")
}
}
Output:
I am running on main
Now I have switched to DefaultDispatcher-worker-1
Function workOnIO
might still block a thread, but not the thread we are calling it from. Also, by following this convention, you won't have to check every time what happens inside a suspending function and whether you need to switch threads for it.
When it comes to CoroutineExceptionHandler
, we will talk about it in more detail when we get to exception handling in coroutines. For now, here is a simple example:
fun main() {
val scope = CoroutineScope(Dispatchers.Default)
val exceptionHandler = CoroutineExceptionHandler { _, throwable ->
println("I have caught: ${throwable.message}")
}
runBlocking {
scope.launch(exceptionHandler) {
println("I am running!")
delay(100)
error("A nasty exception!")
}
delay(200)
println("I am OK!")
}
}
Output:
I am running!
I have caught: A nasty exception!
I am OK!
Lastly, sometimes it might prove handy to identify your coroutines for debug purposes. You can do it using CoroutineName
element:
fun main() {
runBlocking {
launch(CoroutineName("A")) {
identify()
}
launch(CoroutineName("B")) {
identify()
}
}
}
suspend fun identify() {
val coroutineName = coroutineContext[CoroutineName]?.name
println("I am called from: $coroutineName")
}
Output:
I am called from: A
I am called from: B
You can also use the -Dkotlinx.coroutines.debug
JVM option to see the specified coroutine name in the logs.
You should rarely modify a CoroutineContext
outside of these use cases unless you know what you are doing. Otherwise, it might break things you are not even aware of and result in hard-to-find bugs. Especially when introducing a new Job
into the context - a topic we will discuss in a later article.
Conclusion
If you stick to these recommendations, you won't have much trouble using the CoroutineContext
. I could have just given you those and called it an article, but I believe it is crucial to understand the inner workings of the tools we use.
Next time, we will look into the CoroutineScope
, which has more in common with the CoroutineContext
than you might think.
See you next time,
Your friend,
Max