What is CoroutineContext and how does it work?
This is a chapter from the book Kotlin Coroutines. You can find it on LeanPub or Amazon.
If you take a look at the coroutine builders’ definitions, you will see that their first parameter is of type CoroutineContext
.
The receiver and the last argument’s receiver are of type CoroutineScope
1. This CoroutineScope
seems to be an important concept, so let's check out its definition:
It seems to be just a wrapper around CoroutineContext
. So, you might want to recall how Continuation
is defined.
Continuation
contains CoroutineContext
as well. This type is used by the most important Kotlin coroutine elements. This must be a really important concept, so what is it?
CoroutineContext
interface
CoroutineContext
is an interface that represents an element or a collection of elements. It is conceptually similar to a map or a set collection: it is an indexed set of Element
instances like Job
, CoroutineName
, CouroutineDispatcher
, etc. The unusual thing is that each Element
is also a CoroutineContext
. So, every element in a collection is a collection in itself.
This concept is quite intuitive. Imagine a mug. It is a single element, but it is also a collection that contains a single element. When you add another mug, you have a collection with two elements.
In order to allow convenient context specification and modification, each CoroutineContext
element is a CoroutineContext
itself, as in the example below (adding contexts and setting a coroutine builder context will be explained later). Just specifying or adding contexts is much easier than creating an explicit set.
Every element in this set has a unique Key
that is used to identify it. These keys are compared by reference.
For example CoroutineName
or Job
implement CoroutineContext.Element
, which implements the CoroutineContext
interface.
It’s the same with SupervisorJob
, CoroutineExceptionHandler
and dispatchers from the Dispatchers
object. These are the most important coroutine contexts. They will be explained in the next chapters.
Finding elements in CoroutineContext
Since CoroutineContext
is like a collection, we can find an element with a concrete key using get
. Another option is to use square brackets, because in Kotlin the get
method is an operator and can be invoked using square brackets instead of an explicit function call. Just like in Map
: when an element is in the context, it will be returned. If it is not, null
will be returned instead.
CoroutineContext
is part of the built-in support for Kotlin coroutines, so it is imported fromkotlin.coroutines
, while contexts likeJob
orCoroutineName
are part of the kotlinx.coroutines library, so they need to be imported fromkotlinx.coroutines
.
To find a CoroutineName
, we use just CoroutineName
. This is not a type or a class: it is a companion object. It is a feature of Kotlin that a name of a class used by itself acts as a reference to its companion object, so ctx[CoroutineName]
is just a shortcut to ctx[CoroutineName.Key]
.
It is common practice in the kotlinx.coroutines library to use companion objects as keys to elements with the same name. This makes it easier to remember2. A key might point to a class (like CoroutineName
) or to an interface (like Job
) that is implemented by many classes with the same key (like Job
and SupervisorJob
).
Adding contexts
What makes CoroutineContext
truly useful is the ability to merge two of them together.
When two elements with different keys are added, the resulting context responds to both keys.
When another element with the same key is added, just like in a map, the new element replaces the previous one.
Empty coroutine context
Since CoroutineContext
is like a collection, we also have an empty context. Such a context by itself returns no elements; if we add it to another context, it behaves exactly like this other context.
Subtracting elements
Elements can also be removed from a context by their key using the minusKey
function.
The
minus
operator is not overloaded forCoroutineContext
. I believe this is because its meaning would not be clear enough, as explained in Effective Kotlin Item 12: An operator’s meaning should be consistent with its function name.
Folding context
If we need to do something for each element in a context, we can use the fold
method, which is similar to fold
for other collections. It takes:
- an initial accumulator value;
- an operation to produce the next state of the accumulator, based on the current state, and the element it is currently invoked in.
Coroutine context and builders
So CoroutineContext
is just a way to hold and pass data. By default, the parent passes its context to the child, which is one of the parent-child relationship effects. We say that the child inherits context from its parent.
Each child might have a specific context defined in the argument. This context overrides the one from the parent.
A simplified formula to calculate a coroutine context is:
Since new elements always replace old ones with the same key, the child context always overrides elements with the same key from the parent context. The defaults are used only for keys that are not specified anywhere else. Currently, the defaults only set Dispatchers.Default
when no ContinuationInterceptor
is set, and they only set CoroutineId
when the application is in debug mode.
There is a special context called Job
, which is mutable and is used to communicate between a coroutine’s child and its parent. The next chapters will be dedicated to the effects of this communication.
Accessing context in a suspending function
CoroutineScope
has a coroutineContext
property that can be used to access the context. But what if we are in a regular suspending function? As you might remember from the Coroutines under the hood chapter, context is referenced by continuations, which are passed to each suspending function. So, it is possible to access a parent’s context in a suspending function. To do this, we use the coroutineContext
property, which is available in every suspending scope.
Creating our own context
It is not a common need, but we can create our own coroutine context pretty easily. To do this, the easiest way is to create a class that implements the CoroutineContext.Element
interface. Such a class needs a property key
of type CoroutineContext.Key<*>
. This key will be used as the key that identifies this context. The common practice is to use this class’s companion object as a key. This is how a very simple coroutine context can be implemented:
Such a context will behave a lot like CoroutineName
: it will propagate from parent to child, but any children will be able to override it with a different context with the same key. To see this in practice, below you can see an example context that is designed to print consecutive numbers.
I have seen custom contexts in use as a kind of dependency injection - to easily inject different values in production than in tests. However, I don't think this will become standard practice.
Summary
CoroutineContext
is conceptually similar to a map or a set collection. It is an indexed set of Element
instances, where each Element
is also a CoroutineContext
. Every element in it has a unique Key
that is used to identify it. This way, CoroutineContext
is just a universal way to group and pass objects to coroutines. These objects are kept by the coroutines and can determine how these coroutines should be running (what their state is, in which thread, etc). In the next chapters, we will discuss the most essential coroutine contexts in the Kotlin coroutines library.
Let’s clear up the nomenclature. launch
is an extension function on CoroutineScope
, so CoroutineScope
is its receiver type. The extension function’s receiver is the object we reference with this
.
The companion object below is named Key
. We can name companion objects, but this changes little in terms of how they are used. The default companion object name is Companion
, so this name is used when we need to reference this object using reflection or when we define an extension function on it. Here we use Key
instead.