6.
Coroutine Context
Written by Filip Babić
You’re getting pretty handy with coroutines, aren’t ya? In the previous chapters of this book you’ve seen how you can start coroutines, bridge between threads in coroutines to return values, create your own APIs and much more. In the next few chapters, you’ll focus on the internal coroutine concepts. And the most important is the CoroutineContext. Even though it’s at the core of every coroutine, you’ll see that it’s fairly simple after you take a look at its implementation and usage.
Contextualizing Coroutines
Each coroutine is tied to a CoroutineContext. The context is a wrapper around a set of CoroutineContext.Elements, each of which describes a vital part that builds up and forms a coroutine, the way exceptions are propagated and execution flow is navigated or just the general lifecycle.
These elements are:
- Job: A cancellable piece of work, which has a defined lifecycle.
- ContinuationInterceptor: A mechanism which listens to the continuation within a coroutine and intercepts its resumption.
- CoroutineExceptionHandler: A construct which handles exceptions in coroutines.
So, when you run launch, you can pass it a context of your own choice. The context defines which elements will fit into the puzzle. If you pass in a Job, which implements CoroutineContext.Element, you’ll define what the new coroutine’s parent is. As such, if the parent job finishes, it will notify all of its children, including the newly created coroutine.
If you pass in an exception handler, another CoroutineContext.Element, you give the coroutine a way to process errors if something bad happens.
And the last thing you can pass in is a ContinuationInterceptor. These constructs control the flow of each coroutine-powered function, by determining which thread it should operate on and how it should distribute work.
You wouldn’t want to write a full implementation that manually handles continuations. If you want something to do that for you, while also being a CoroutineContext.Element, you have to provide a coroutine dispatcher.
You’ve used some of them before — like Dispatchers.Default. So the key to understanding ContinuationInterceptor usage is by learning what a dispatcher really is, which you’ll do in “Chapter 7: Context Switch & Dispatching”. For now, you’ll focus on combining and providing CoroutineContexts.
Using CoroutineContext
To follow the code in this chapter, open this chapter’s starter project using IntelliJ by selecting Open Project. Then navigate to the coroutine-context/projects/starter folder, selecting the coroutine-context project.
Even though you haven’t gone too deep into it, you’ve already used CoroutineContext extensively. Every time you’ve created a coroutine, from a CoroutineScope, you’ve passed in the scope’s CoroutineContext to the builders. Take the following snippet for an example:
GlobalScope.launch {
println("In a coroutine")
}
You don’t see it, but there’s work done around the CoroutineContext for this simple snippet of code. Once again, if you look at the definiton of launch, this is what you can see:
public fun CoroutineScope.launch(
context: CoroutineContext = EmptyCoroutineContext,
start: CoroutineStart = CoroutineStart.DEFAULT,
block: suspend CoroutineScope.() -> Unit
): Job
You can see that the default context is the EmptyCoroutineContext. This basically means it’s going to use the most default behavior - no special lifecycle, no exception handling from within the coroutine and most importantly - no custom threading. Further along, launch calls newCoroutineContext(context), to build up a full context for the coroutine. The code underneath is a bit complex, but in essence if the context is fully empty, it adds the Dispatchers.Default to it, adding default background worker threading.
So even though you don’t see it, the API itself uses contexts to achieve at least the default behavior. But you should always strive to explicitly and clearly provide what you want to happen.
Combining contexts can lead to very powerful mechanisms, so let’s see what it’s about.
Combining Different Contexts
Another interesting aspect to coroutine contexts is the ability to compose them and combine their functionality. Using the +/plus operator, you can create a new CoroutineContext from the combination of the two. Since you know each coroutine is composed of several objects, like the continuation interceptor for the threading, exception handler for errors and a Job for lifecycle, there has to be a way to create a new coroutine with all these pieces of the puzzle. And this is where summing contexts comes in handy. You can do it as simply as this:
fun main() {
val defaultDispatcher = Dispatchers.Default
val coroutineErrorHandler = CoroutineExceptionHandler { context, error ->
println("Problems with Coroutine: $error") // we just print the error here
}
val emptyParentJob = Job()
val combinedContext = defaultDispatcher + coroutineErrorHandler + emptyParentJob
GlobalScope.launch(context = combinedContext) {
println(Thread.currentThread().name)
}
Thread.sleep(50)
}
The code above is an example of how to create a context from both the Dispatchers.Default and the error handler. As such, you can add more functionality to a context all at once, effectively building up all of the features your coroutine should use — error handling, threading and lifecycle.
If you run the code above, you’ll see an output that’s similar to this:
DefaultDispatcher-worker-1
Process finished with exit code 0
Even though it only prints out the Thread name, there are powerful mechanisms working under the hood.
So, if you’ve built a complex CoroutineContext, like above, it would be cool to be able to use it in every coroutine you create. The same goes for the lifecycle of coroutines and their threading mechanisms.
By summing CoroutineContexts, you combine all of their CoroutineContext.Elements, creating a union of their functionality. However, there are some things which don’t make sense, like combining two different Dispatchers. This would mean the second dispatcher’s threading will override the first one’s. If you try to do that, the compiler will even give you a message saying it doesn’t make sense.
Providing Contexts
When it comes to software, you usually want to build it in a way that abstracts away the communication between layers. With threading, it’s useful to abstract the way you switch between different threads. You can abstract this by attaching a thread provider, providing both main and background threads. It’s no different with coroutines!
Since the threading mechanism is abstracted with CoroutineContexts and their respective CoroutineDispatcher instances, you can build a provider that you’d use to delegate which context should be used every time you build coroutines. Usually, these providers have a declared interface, which gives you the main and background threads or schedulers, since that’s what’s important in applications with user interfaces.
Let’s see how you’d build such a provider.
Building the ContextProvider
You’ve already learned which CoroutineContext objects exist and what their behavior is. To build the provider, you first have to define an interface, which provides a generic context, which you’ll run the expensive operations on. Note that this Provider interface is not part of Coroutines API but will help us abstract out the main and background contexts. The interface would look like this:
interface CoroutineContextProvider {
fun context(): CoroutineContext
}
This way, you can build many different CoroutineContextProviders, each for a specific use case you may have in mind. In the implementation, you can pass in the required CoroutineContext to the constructor, abstracting away the information in a factory function, or your dependency injection graph, like so:
class CoroutineContextProviderImpl(
private val context: CoroutineContext
) : CoroutineContextProvider {
override fun context(): CoroutineContext = context
}
This way, whenever you’re building coroutines, you can use the context provider. Don’t worry about creating this class, it’s already predefined for you!
Now open Main.kt and change main to the following:
fun main() {
val parentJob = Job()
val provider: CoroutineContextProvider = CoroutineContextProviderImpl(
context = parentJob + Dispatchers.IO
)
GlobalScope.launch(context = provider.context()) {
println(Thread.currentThread().name)
}
Thread.sleep(50)
}
and import the provided CoroutineContextProvider and CoroutineContextProviderImpl. In the snippet above, you first create the CoroutineContextProvider by combining the parentJob and Dispatchers.IO as the CoroutineContext. This will make sure that any coroutines launched using provider.context() have the same parent Job and run in the background.
Because of this you’re able to depend on the abstract provider of contexts, rather than manually writing all of the contexts you use. Additionally, when you build the provider, you could pass in any context you want, effectively switching out the pools of threads that let the event of work swim in. If you wanted to use a main thread bound context, you can pass in the following:
val mainContextProvider: CoroutineContextProvider =
CoroutineContextProviderImpl(Dispatchers.Main)
You could go another step forward and make the context provider expose not only thread-based contexts, but also error handling contexts and a lifecycle-related context. Or virtually any combination of those coroutine context elements.
This is extremely useful if you want to abstract away the contexts you’re using often. Additionally, it can help you with testing, which you’ll see in the “Chapter 13: Testing Coroutines”.
Key Points
- All the information for coroutines is contained in a
CoroutineContextand itsCoroutineContext.Elements. - There are three main coroutine context elements: the
Job, which defines the lifecycle and can be cancelled, aCoroutineExceptionHandler, which takes care of errors, and theContinuationInterceptor, which handles function execution flow and threading. - Each of the coroutine context elements implements
CoroutineContext. -
ContinuationInterceptors, which take care of the input/output of threading. The main and background threads are provided through theDispatchers. - You can combine different
CoroutineContexts and theirElements by using the+/plusoperator, effectively summing their elements. - A good practice is to build a
CoroutineContextprovider, so you don’t depend on explicit contexts. - With the
CoroutineContextProvideryou can abstract away complex contexts, like custom error handling, coroutine lifecycles or threading mechanisms. - The
CoroutineContextProvideris very useful in testing as you can abstract away the context that is specific to the testing environment.