Why does this repository hang forever on the first call to user()?
This repository confines every database call to one dedicated thread. The first call to user() never returns and the thread is never released again — the app hangs.
Constraints: keep the single-thread confinement of the database work, do not add a second dispatcher, and do not block a thread anywhere.
class UserRepository(private val db: Db) {
private val dbDispatcher = newSingleThreadContext("db")
private val cache = mutableMapOf<Long, User>()
suspend fun user(id: Long): User = withContext(dbDispatcher) {
cache[id] ?: load(id)
}
private fun load(id: Long): User = runBlocking {
val fresh = withContext(dbDispatcher) { db.load(id) }
cache[id] = fresh
fresh
}
}
Find and fix the bug.
load is called while already on dbDispatcher, and runBlocking blocks that one thread. The withContext(dbDispatcher) inside then queues work for the very thread that is parked, so neither side can progress — a deadlock. Make load a suspend fun, call db.load(id) directly and delete the runBlocking.
- ✗Using
runBlockingto call asuspendfunction from ordinary code that already runs inside a coroutine - ✗Thinking
runBlockingsuspends its caller instead of blocking the caller's thread - ✗Assuming a
withContextonto the dispatcher you are already on is always a free no-op
- →Why does the same nesting deadlock on
Dispatchers.Mainbut often survive onDispatchers.IO? - →How would you make this deadlock deterministic in a unit test?
The bug
user() already runs on dbDispatcher — a single thread. From there it calls the ordinary function load, which uses runBlocking to block that thread while waiting for its body. The body then calls withContext(dbDispatcher), queueing work for the very thread that is parked inside runBlocking. Nobody is left to drain the queue: deadlock.
suspend fun user(id: Long): User = withContext(dbDispatcher) { // thread "db" is busy
cache[id] ?: load(id)
}
private fun load(id: Long): User = runBlocking { // ❌ blocks thread "db"
val fresh = withContext(dbDispatcher) { db.load(id) } // ❌ waits for thread "db"
cache[id] = fresh
fresh
}
The fix
Remove the blocking bridge: make load a suspend fun and call it directly. It already runs on dbDispatcher, so the nested withContext is unnecessary — the database work stays confined to one thread, and no thread is ever blocked.
suspend fun user(id: Long): User = withContext(dbDispatcher) {
cache[id] ?: load(id)
}
private suspend fun load(id: Long): User { // ✅ suspend, no runBlocking
val fresh = db.load(id) // ✅ already on thread "db"
cache[id] = fresh
return fresh
}
runBlocking is the bridge from the blocking world into coroutines: it belongs in main and in tests, not inside a coroutine. On a single-threaded dispatcher that bridge is guaranteed to run into itself.