Kotlin Coroutine mechanisms part 3: swapping CoroutineContext

Part 3 — CoroutineContext, Dispatchers, runContext, and Android viewModelScope explained

Amanda Hinchman
6 min readSep 16, 2024
Photo credit to Life-of-Pix

This series serves as spin off from Programming Android with Kotlin: Achieving Structured Concurrency with Coroutines intended to help strengthen everyday coroutine understanding through playful explorations. We [the authors] have always had sincere intentions writing the book:

While [coroutine] concepts are important if you want to master coroutines, you don’t have to understand everything right now to get started and be productive with coroutines. — Chapter 9: Coroutine concepts p. 127

References to the book may be denoted as Programming Android with Kotlin for brevity. Navigate around for previous articles in this series:

To follow along for the Android portion of explorations, download this starter Android project.

Last we left off

So far, we’ve explored how runBlocking, launch, and async behaviors interact with one another. Our latest code block looked something like this:

fun main() = runBlocking {

val job = launch(Dispatchers.Default) {
...
val task1: Job = launch {
/** Fire-and-forget heavy work **/
}
val task2: Deferred<String> = async {
/** Do heavy work and wait for return **/
}

// wait for coroutine to complete before moving on
task1.join()

val task3 = launch {
/** Fire-and-forget heavy work **/
}
...
// return answer to task2 here
println(task2.await())
...
}

Perhaps you’ve noticed how job and task invokes the launch call two ways, with a designated CoroutineContext and without. In pseudocode:

job = launch(context) { 
...
task = launch { ...}
...
}

What does this do? What is a coroutine context? Why does it matter when using Kotlin coroutines? This article intends to answer these questions via Android.

What is CoroutineContext?

CoroutineContext , or for brevity, context, represents persistent context for a coroutine. In the case of a coroutine, context designates what task executes where. Managing context is important - switching what task executes on what thread or thread pool gives capacity to tweak concurrency behavior.

Context be used from the Dispatchers.kt class. In our Android project, CoroutineContextDispatcher is written to allow for dependency injection:

JVM Kotlin documentation and Dispatcher base platform class describes nuances between each of these context elements:

Dispatchers.Main

[The Main] coroutine dispatcher that is confined to the Main thread operating with UI objects. Usually such dispatchers are single-threaded — Dispatchers.common.kt.

On the JVM, using Dispatchers.Main requires another dependency offered in Android, JavaFX, and Swing. On JS, it is equivalent to Dispatchers.Default. For Native targets, Dispatchers.Main is not available unless using a Darwin-based target.

Dispatchers.Default

Dispatchers.Default is the default context argument for standard coroutine builders. The maximum size of parallel threads that can be used for its assigned task is equal to the number of processors available on the CPU. Dispatchers.Default is best for CPU-intensive tasks.

[Dispatchers.Default] is backed by a shared pool of threads on JVM and Native. By default, the maximum number of threads used by this dispatcher is equal to the number of CPU cores, but is at least two. — Dispatchers.common.kt :

Dispatchers.IO

Dispatchers.IO indicates an elastic thread pool that defaults to 64 threads or cores, whichever number is larger. It’s perfect for IO tasks, since threads in this pool is expected to spend most of the time in a non-running state.

[Dispatchers.IO] and its views share threads with the Dispatchers.Defaultdispatcher, so using withContext(Dispatchers.IO) { … } when already running on the Dispatchers.Default dispatcher typically does not lead to an actual switching to another thread. — JVM Dispatchers.kt:

Dispatchers.Unconfined

Dispatchers.Unconfined is not defined in the class above; it is intended for internal use and should not be used in production code. As the naming suggests, it is not confined to any thread. Kotlin documentation provides additional detail on the nuances of behavior of this dispatcher.

This dispatcher is best used in unit testing. In the case of our Android project, we can override the CoroutineContextProvider interface and assign all values to Dispatcher.Unconfined. We won’t cover unit testing today, but hey, I’ll get to it sometime if enough requests come in.

How can CoroutineContext be used?

As mentioned in the beginning of the article, context in coroutines can be used in different ways:

  • Within Coroutine builder extension functions like launch(context)
  • CoroutineScope(context) — context defined within a CoroutineScope
  • withContext(context) — as a way to swap context within a specific coroutine

Within a Coroutine builder extension functions i.e. `launch(context)`

All standard coroutine builders can be launched with a designated coroutine context. In the implementation for launch, its first argument context has default value EmptyCoroutineContext:

public fun CoroutineScope.launch(
context: CoroutineContext = EmptyCoroutineContext,
...
block: suspend CoroutineScope.() -> Unit
): Job { ... }

For this reason, you can invoke builders without a context argument:

runBlocking {...}
launch {...}
async {...}
withContext {...}

On startup, our application calls loadContent to do some heavy work and render the screen after.

doHeavyWork is a suspending function denoted by the keyword suspend, and operates on the the current context of the task it was called from. Within function execution, a suspending function may pause at suspending points to be resumed at a later time.

When doHeavyWork starts, child coroutines launches in the forEach statement. These child coroutines are set to Dispatchers.IO via launch argument to complete some IO work.

doHeavyWork is meant to represent IO work i.e. network call

When doHeavyWork completes and result returns, execution shifts back to the original dispatcher with the result. That result can then update UI via observable viewState. In the next section, we look closer at setting context within scope.

CoroutineContext defined within `CoroutineScope(context)`

Historically, I’ve found explanations of context and scope in other frameworks to be a bit circular. Luckily, Programming Android with Kotlin succinctly describes CoroutineScope:

A CoroutineScope controls the lifecycle of a coroutine within a well-defined scope or lifecycle. Its purpose is to manage and monitor the coroutines you create inside of it . Chapter 9: Coroutine Concepts, p. 128

CoroutineScope holds a reference to the CoroutineContext so that it encapsulates other coroutine tasks within.

Take the case of the MainActivityViewModel, which makes use of LiveData to reflect back in Composable UI. MainActivityViewModel makes use of both viewModelScope and a custom scope.

By default, viewModelScope launches coroutines on Dispatchers.Main. When loadContent is called, viewModelScope launches an Activity lifecycle-aware coroutine. Within, scope.launch then calls on doHeavyWork to start.

When we run the application, it immediately crashes. Can you guess why?

FATAL EXCEPTION: DefaultDispatcher-worker-1
Process: com.example.kotlincoroutinemechanisms, PID: 8121
java.lang.IllegalStateException: Cannot invoke setValue on a background thread
at androidx.lifecycle.LiveData.assertMainThread(LiveData.java:502)
at androidx.lifecycle.LiveData.setValue(LiveData.java:306)
at androidx.lifecycle.MutableLiveData.setValue(MutableLiveData.java:50)
at com.example.kotlincoroutinemechanisms.MainActivityViewModel$loadContent$1$1.invokeSuspend(MainActivityViewModel.kt:35)
...
Suppressed: kotlinx.coroutines.internal.DiagnosticCoroutineContextException: [StandaloneCoroutine{Cancelling}@522a2d9, Dispatchers.Default]

Because scope is set to Dispatchers.Default, doHeavyWork runs on a background thread, but the work does not return to the Main thread context launched by viewModelScope. When doHeavyWork completes, its result returns and execution shifts back to the original dispatcher withresult.

We can then update observable viewState with result once retrieved. However, the code attempts to update _viewState.value with result while still in the background thread. To fix this issue,withContext comes to the rescue.

CoroutineContext defined within `withContext(context)`

When working with background execution on viewModelScope or a coroutine task, you can use withContext(context) to switch the current task execution to a more appropriate dispatcher.

However, the code attempts to update _viewState.value with result while still in the background thread.

When the result returns from background thread work in doHeavyWork, we can then switch the context at arrival of our Result type using withContext(contextPool.mainDispatcher):

When we run the application again, the application does not crash. In logging, we get:

suspend doHeavyWork() | current thread: DefaultDispatcher-worker-1
task1 | count: 0 | current thread: DefaultDispatcher-worker-2
task1 | count: 1 | current thread: DefaultDispatcher-worker-2
task1 | count: 2 | current thread: DefaultDispatcher-worker-2
task1 | count: 3 | current thread: DefaultDispatcher-worker-2
task1 | count: 4 | current thread: DefaultDispatcher-worker-2
loadContent succeeded: Success(data=5) | current thread: main

Lesson Recap:

  • CoroutineContext designates what task executes on which thread or thread pool. We can use it to tweak the behavior of concurrent flow.
  • There are 4 dispatchers that can be used: Dispatchers.Default , Dispatchers.Main , Dispatchers.IO , Dispatchers.Unconfined
  • CoroutineContext can be used in 3 different ways: launch(context) , CoroutineScope(context)and withContext(context)

Want more content like this?

That’s all for now for this series. Keep an eye out for news of online workshop content coming soon — hit subscribe to get the latest updates on more content!

For more in-depth content related to this material, consider looking into Programming Android with Kotlin: Achieving Structured Concurrency with Coroutinesnamely, Chapter 9: Coroutines concepts.

--

--

Amanda Hinchman
Amanda Hinchman

Written by Amanda Hinchman

Kotlin GDE and Android engineer. Co-author of O'Reilly's "Programming Android with Kotlin: Achieving Structured Concurrency with Coroutines"