Dispatchers.Unconfined and why you actually want EmptyCoroutineContext

Dispatchers.Unconfined is one of kotlinx.coroutines’ built in CoroutineDispatchers. It’s different from other built in dispatchers as it’s not backed by a thread pool or other asynchronous primitive. Instead, Dispatchers.Unconfined is hardcoded to never change threads when entering its context (this is called “dispatching”). It’s pretty easy to verify this from its (simplified) implementation:

object Unconfined : CoroutineDispatcher() {
  override fun isDispatchNeeded(context: CoroutineContext) = false

  override fun dispatch(context: CoroutineContext, block: Runnable) {
    throw UnsupportedOperationException()
  }
}

This behavior is different from Dispatchers.Main or Dispatchers.Default, which will change threads if you’re not already on one of their preferred thread(s). As a result, code using Dispatchers.Unconfined will always execute synchronously when entering its context.

In practice, this means that any code inside of the Dispatchers.Unconfined has no guarantees about what thread it will run on. This can create subtle bugs as dispatching occurs both when entering a new context and when returning from it. Consider this example where we read some text on the IO dispatcher then update the main thread with the result:

// Pretend these dispatchers are injected.
val ioDispatcher = Dispatchers.IO
val mainDispatcher = Dispatchers.Main

withContext(ioDispatcher) {
  val firstText = readFile(1)
  val secondText = readFile(2)
  withContext(mainDispatcher) {
    textView.text = firstText
    delay(1.seconds)
    textView.text = secondText
  }
}

If we’re testing this function, say in a screenshot test, and we know our test starts on the main thread we may want to avoid dispatching entirely so our test executes synchronously on the calling dispatcher. We can do this by injecting Dispatchers.Unconfined for our IO and main dispatchers:

val ioDispatcher = Dispatchers.Unconfined
val mainDispatcher = Dispatchers.Unconfined

withContext(ioDispatcher) {
  val firstText = readFile(1)
  val secondText = readFile(2)
  withContext(mainDispatcher) {
    textView.text = firstText
    delay(1.seconds)
    textView.text = secondText // This line will crash!
  }
}

However, this change introduces a crash as delay changes the context to Dispatchers.Default internally and because we’re using Dispatchers.Unconfined we never dispatch back to the main thread. When we try to update textView’s text it will throw a CalledFromWrongThreadException.

This example also shows how Dispatchers.Unconfined breaks one of coroutines’ best features: making threading a local consideration. When we use Dispatchers.Main or Dispatchers.Default we don’t have to worry about dispatching back to the right thread after calling another suspend fun - it’s handled for us.

There’s a better way

Typically we use withContext to change the CoroutineDispatcher, but withContext actually accepts a CoroutineContext. You can think of CoroutineContext as equivalent to Map<CoroutineContext.Key, CoroutineContext.Element>. When we invoke withContext(Dispatchers.Unconfined) we’re overwriting the current context’s CoroutineDispatcher key with Dispatchers.Unconfined.

Instead, we should use EmptyCoroutineContext as it doesn’t update the current context’s CoroutineDispatcher. This means we don’t dispatch when calling withContext(EmptyCoroutineContext) as the coroutine context doesn’t change but we’ll still dispatch back to the right thread if another function like delay changes the context. Let’s reexamine the above example using EmptyCoroutineContext instead of Dispatchers.Unconfined:

val ioDispatcher = EmptyCoroutineContext
val mainDispatcher = EmptyCoroutineContext

withContext(ioDispatcher) {
  val firstText = readFile(1)
  val secondText = readFile(2)
  withContext(mainDispatcher) {
    textView.text = firstText
    delay(1.seconds)
    textView.text = secondText // Does not crash.
  }
}

Using EmptyCoroutineContext lets us continue executing synchronously on the main thread and avoids crashing as we correctly dispatch back to the main thread after delay. It’s for these reasons that at Cash App we inject all our dispatchers as CoroutineContexts and inject EmptyCoroutineContext in our tests:

class MoneyPresenter @Inject constructor(
    @IoDispatcher private val ioDispatcher: CoroutineContext,
)

In fact, there are actually very few cases where you need to reference the CoroutineDispatcher class at all. CoroutineScope(), withContext, and CoroutineContext.plus all accept a CoroutineContext. CoroutineContext is also more flexible as there are other elements you can add like CoroutineName for debugging purposes. I’d recommend replacing all your references to CoroutineDispatcher with CoroutineContext - especially if you maintain a public API. Coil updated its public API to accept CoroutineContext instead of CoroutineDispatcher in 3.0. Thanks to Bill Phillips for suggesting this change!

Also thanks to Bill Phillips, Jesse Wilson, and Raheel Naz for reviewing this blog post.