Demystifying CoroutineContext
At the heart of Kotlin coroutines is the CoroutineContext
interface. All the coroutine builder functions like launch
and async
have the same first parameter, context: CoroutineContext
. These coroutine builders are also all defined as extension functions on the CoroutineScope
interface, which has a single abstract read-only property, coroutineContext: CoroutineContext
.
Every coroutine builder is an extension on CoroutineScope and inherits its coroutineContext to automatically propagate both context elements and cancellation.
fun CoroutineScope.launch(
context: CoroutineContext = EmptyCoroutineContext,
start: CoroutineStart = CoroutineStart.DEFAULT,
block: suspend CoroutineScope.() -> Unit
): Job (source)
fun <T> CoroutineScope.async(
context: CoroutineContext = EmptyCoroutineContext,
start: CoroutineStart = CoroutineStart.DEFAULT,
block: suspend CoroutineScope.() -> T
): Deferred<T> (source)
CoroutineContext
is a fundamental building block of Kotlin coroutines. Being able to manipulate it is therefore vital in order to achieve the correct behavior for threading, life-cycle, exceptions, and debugging.
Structure
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. Keys are compared by reference.
The API of the CoroutineContext
interface might seem obscure at first, but it’s actually just a type-safe heterogeneous map, from CoroutineContext.Key
instances (compared by reference, not value, as per the class documentation) to CoroutineContext.Element
instances. To understand why a new interface had to be redefined rather than simply using a standard Map
, consider the equivalent declaration of the context.
typealias CoroutineContext =
Map<CoroutineContext.Key<*>, CoroutineContext.Element>
The get
method is then incapable of inferring the Element
in the response from the key which was used, even though this information is actually available in the generic type of the key.
fun get(key: CoroutineContext.Key<*>): CoroutineContext.Element?
So, whenever an element is fetched from the map, it needs to be cast to the actual type. But in the CoroutineContext
class, the generic get
method actually defines the returned Element
type based on the generic type of the Key
passed as argument.
fun <E : Element> get(key: Key<E>): E?
This way elements can safely be fetched without the need to type-cast, because their type was specified in the key which was used.
Operations
CoroutineContext
doesn’t implement a collection interface, so it doesn’t have the typical collection operators. There is one important operator though, plus
. The plus
operator combines CoroutineContext
instances with each other. This will merge the elements they contain, overwriting the elements in the context on the left-hand side of the operator with the ones in the context on the right-hand side, much like the behavior on Map
.
[The plus operator] 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.
TheCoroutineContext.Element
interface actually inherits CoroutineContext
. This is handy because it means that CoroutineContext.Element
instances can be simply treated as CoroutineContext
s containing a single element, themselves.
An element of the coroutine context is a singleton context by itself.
With this, the +
operator can be used to easily combine contexts with elements and elements with each other into a new context. The important thing to watch out for is the order in which they are combined, since the +
operator is asymmetrical.
In cases where a context shouldn’t hold any elements, the EmptyCoroutineContext object can be used. As can be expected, adding this object to any other context has no effect on that context.
Elements
As explained, CoroutineContext
is essentially a map, and it always holds a predefined set of items. Since all the keys have to implement the CoroutineContext.Key
interface, it’s easy to find the list of public Element
s by searching through the source code for the implementations of CoroutineContext.Key
and checking which Element
class they’re associated with.
ContinuationInterceptor
is invoked for continuations, to manage the underlying execution threads. In practice, implementations always extend theCoroutineDispatcher
base class.Job
models the life-cycle and task hierarchy in which a coroutine is being executed.CoroutineExceptionHandler
is used by coroutine builders which don’t propagate exceptions, namelylaunch
andactor
, in order to determine what to do if an exception is encountered. See the dedicated CoroutineExceptionHandler section in the guide for more details.CoroutineName
is used for debugging purposes. See the dedicated section on Naming coroutines for debugging in the guide for more details.
Each key is defined as the companion object of its associated Element
interface or class. This way, keys can be directly referred to by using the element type names. For example, coroutineContext[Job]
will return the instance of Job
held by coroutineContext
, or null
if it doesn’t contain any.
If extensibility wasn’t a factor, CoroutineContext
could simply be modelled as a class.
class CoroutineContext(
val continuationInterceptor: ContinuationInterceptor?,
val job: Job?,
val coroutineExceptionHandler: CoroutineExceptionHandler,
val name: CoroutineName?
)
CoroutineScope
builders
When we want to start a coroutine, we need to call a builder function on a CoroutineScope
instance. In the builder function, we can actually see three contexts coming into play.
- The
CoroutineScope
receiver is defined by the way it provides aCoroutineContext
. This is the inherited context. - The builder function receives a
CoroutineContext
instance in its first parameter. We’ll call this the context argument. - The suspending block parameter in the builder function has a
CoroutineScope
receiver, which itself also provides aCoroutineContext
. This is the coroutine context.
Looking at the source for launch
and async
, they both start with the same statement.
val newContext = newCoroutineContext(context)
The newCoroutineContext
extension function on CoroutineScope
handles merging the inherited context with the context argument, as well as providing default values and doing a bit of extra configuration. The merge is written as coroutineContext + context
, where coroutineContext
is the inherited context and context
is the context argument. Given what’s been explained previously about the CoroutineContext.plus
operator, the right-hand operand takes precedence, therefore the attributes from the context argument will overwrite those in the inherited context. The result is what we’ll call the parent context.
parent context = default values + inherited context + context argument
The CoroutineScope
instance passed as receiver to the suspending block is actually the coroutine itself, always inheriting AbstractCoroutine
, which implements CoroutineScope
and is also a Job
. The coroutine context is provided by this class and will return the parent context obtained previously, to which it adds itself, effectively overriding the Job
.
coroutine context = parent context + coroutine job
Defaults
When elements are missing from a context which is being used by a coroutine, it uses a default value.
- The default
ContinuationInterceptor
isDispatchers.Default
. This is documented innewCoroutineContext
. Therefore if neither the inherited context nor the context parameter have a dispatcher, then the default dispatcher is used. In that case, the coroutine context will also inherit the default dispatcher. - If the context doesn’t have a
Job
, then the coroutine which is created doesn’t have a parent. - If the context doesn’t have a
CoroutineExceptionHandler
, then the global handler is used (but not installed in the context). This will ultimately callhandleCoroutineExceptionImpl
which first uses the java ServiceLoader to load all implementations ofCoroutineExceptionHandler
, then propagates the exception to the current thread’s uncaught exception handler. On Android, a special exception handler namedAndroidExceptionPreHandler
is automatically installed to report exceptions to the hiddenuncaughtExceptionPreHandler
property onThread
, which logs exceptions to the console, but it will after cause the application to crash. - The default name for a coroutine is
"coroutine"
, hardcoded at places where theCoroutineName
key is used to fetch the name from a context.
Looking at the modelling previously proposed of CoroutineScope
as a class, it can be clarified by adding the default values observed in the actual implementation.
val defaultExceptionHandler = CoroutineExceptionHandler { ctx, t ->
ServiceLoader.load(
serviceClass,
serviceClass.classLoader
).forEach{
it.handleException(ctx, t)
}
Thread.currentThread().let {
it.uncaughtExceptionHandler.uncaughtException(it, exception)
}
}class CoroutineContext(
val continuationInterceptor: ContinuationInterceptor =
Dispatchers.Default,
val parentJob: Job? =
null,
val coroutineExceptionHandler: CoroutineExceptionHandler =
defaultExceptionHandler,
val name: CoroutineName =
CoroutineName("coroutine")
)
Usage
Through some examples, let’s look at the resulting context in some coroutine expressions, and most importantly which dispatchers and parent jobs they inherit.
Global Scope Context
GlobalScope.launch {
/* ... */
}
If we check the source of GlobalScope
, we see that its implementation of coroutineContext
always returns an EmptyCoroutineContext
. The resulting context used by the coroutine will therefore use all the default values. The above statement is for example identical to the following one, where the default dispatcher is explicitly specified.
GlobalScope.launch(Dispatchers.Default) {
/* ... */
}
Fully Qualified Context
Inversely, we can specify all the elements within a context passed as argument.
coroutineScope.launch(
Dispatchers.Main +
Job() +
CoroutineName("HelloCoroutine") +
CoroutineExceptionHandler { _, _ -> /* ... */ }
) {
/* ... */
}
None of the elements from the inherited context will be actually be taken into account. This statement has the same behavior no matter the CoroutineScope
on which it’s called.
CoroutineScope Context
In the coroutines UI programming guide for Android, we find the following example in the Structured concurrency, lifecycle and coroutine parent-child hierarchy section, showing how to implement a CoroutineScope
in an Activity
.
abstract class ScopedAppActivity: AppCompatActivity()
{
private val scope = MainScope()
override fun onDestroy() {
super.onDestroy()
scope.cancel()
} /* ... */
}
In this suggestion, the MainScope
helper factory function is used to create a scope with the predefined UI dispatcher and a supervisor job. This is a design choice, so that all coroutine builders called on this scope will use the Main
dispatcher rather than Default
. Defining elements in the scope’s context is a way to override the library defaults in the places where the context is used. The scope also provides a job
, so that all coroutines launched from this context have the same parent. This way, there’s a single point from which to cancel them all, which is bound to the activity’s lifecycle.
Overriding Parent Job
We can have some context elements inherited from scope and other added in the context parameter, so that the two are combined. For example, when using the NonCancellable job, it’s typically the only element in the context passed as argument.
withContext(NonCancellable) {
/* ... */
}
The code executed within this block will inherit the dispatcher from its calling context, but it will override that context’s job by using NonCancellable
as parent. This way, the coroutine will always be in an active state.
Binding to Parent Job
When using launch
and async
, which are provided as extensions on CoroutineScope
, the scope’s elements (including the job) are automatically inherited. However, when using a CompletableDeferred
, which is a useful tool to bind callback-based APIs to coroutines, the parent job needs to be manually provided.
val call: Call
val deferred = CompletableDeferred<Response>()
call.enqueue(object: Callback {
override fun onResponse(call: Call, response: Response) {
completableDeferred.complete(response)
}
override fun onFailure(call: Call, e: IOException) {
completableDeferred.completeExceptionally(e)
}
})
deferred.await()
This type of construct makes it easy to await the result of a call. However, with regards to structured concurrency, the deferred could be leaked if it isn’t cancelled. The easiest way to make sure that the CompletableDeferred
is properly cancelled would be to bind it to its parent job.
val deferred = CompletableDeferred<Response>(coroutineContext[Job])
Accessing Context Elements
The elements in the current context can be obtained by using the top-level suspending coroutineContext
read-only property.
println("Running in ${coroutineContext[CoroutineName]}")
The above statement can for example be used to print the name of the current coroutine.
If we want we can actually rebuild a coroutine context identical to the current one, from its individual elements.
val inheritedContext = sequenceOf(
Job,
ContinuationInterceptor,
CoroutineExceptionHandler,
CoroutineName
).mapNotNull { key -> coroutineContext[key] }
.fold(EmptyCoroutineContext) { ctx: CoroutineContext, elt ->
ctx + elt
}
launch(inheritedContext) {
/* ... */
}
Although interesting to understand the composition of the context, this example is completely useless in practice. We would obtain exactly the same behavior by leaving the context parameter of launch
to its default empty value.
Nested Context
This last example is important because it presents a change of behavior in the latest releases of coroutines, where the builder functions became extensions on CoroutineScope
.
GlobalScope.launch(Dispatchers.Main) {
val deferred = async {
/* ... */
}
/* ... */
}
Given that async
is called on the scope (instead of being a top-level function), it will inherit the scope’s dispatcher, specified as Dispatchers.Main
by launch
, rather than using the default one. In previous versions of coroutines, the code within async
would run on a worker thread provided by Dispatchers.Default
, but it will now run on the UI thread, which could cause the application to hang or even crash.
The solution is simply to be more explicit about the dispatcher being used in async
.
launch(Dispatchers.Main) {
val deferred = async(Dispatchers.Default) {
/* ... */
}
/* ... */
}
Coroutine API Design
The coroutine API is designed to be flexible and expressive. By combining contexts using a simple +
operator, the language designers have made possible to easily define the properties of a coroutine when launching it, and to inherit these properties from the execution context. This gives developers complete control over their coroutines while keeping the syntax fluent.