Dispatchers.Unconfined and why you actually want EmptyCoroutineContext
Dispatchers.Unconfined
is one of kotlinx.coroutines
’ built in CoroutineDispatcher
s. 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 CoroutineContext
s 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.