Max Kim
Art and science of writing good code

Art and science of writing good code

Things every Kotlin Developer should know about Coroutines. 

Part 1: CoroutineContext.

Things every Kotlin Developer should know about Coroutines. Part 1: CoroutineContext.

Max Kim's photo
Max Kim

Published on Dec 2, 2021

8 min read

Subscribe to my newsletter and never miss my upcoming articles

Listen to this article

Table of contents

  • CoroutineContext Under the Hood
  • CoroutineContext Elements
  • Modifying a CoroutineContext
  • Best Practices and Recommendations
  • Conclusion

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 Elements 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 Elements, it is curious how the coroutines library uses typed Keys.

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 Elements as in the example above, but when combining sets of Elements, 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 Elements and/or CoroutineContexts.

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

Did you find this article valuable?

Support Max Kim by becoming a sponsor. Any amount is appreciated!

Learn more about Hashnode Sponsors
 
Share this