What are the dangers of calling runBlocking from inside a coroutine or suspend function, and how do you fix such code?
answer
- Blocks the carrier thread -> starvation/deadlock
- Default pool ~= cores; easy to exhaust
- Breaks cooperative cancellation
- Fix: coroutineScope / withContext / async-await
- runBlocking only at the outermost boundary
basics
~20 sIt blocks a real thread that the coroutine system needs. On a small thread pool this wastes or even starves threads and can deadlock. Replace it with withContext, coroutineScope, or async/await — none of which block a thread.
solid answer
~50 sSuspend code is built so coroutines suspend cooperatively without holding threads. Calling `runBlocking` inside a suspend function or coroutine breaks that: it **blocks the carrier thread** for the whole nested computation. On a bounded dispatcher like `Dispatchers.Default` (sized to CPU count) or a small custom pool, blocking enough carrier threads causes **thread starvation** — and if the blocked code needs those very threads to make progress, **deadlock**. It also defeats coroutine cancellation: a thread parked in runBlocking won't cooperatively cancel. The fix is to stay suspending: use `coroutineScope { ... }` to await structured children, `withContext(dispatcher) { ... }` to switch context for a sub-task, or `async { }.await()` for concurrent results. If you genuinely must call a blocking JDBC/IO API, wrap *that* call in `withContext(Dispatchers.IO)` instead of bridging back with runBlocking. The rule of thumb: runBlocking belongs only at the outermost blocking boundary, never nested inside the coroutine world.
code
kotlin · 13 linesimport kotlinx.coroutines.*
// BEFORE: nested runBlocking blocks a coroutine thread
suspend fun before(): Int = withContext(Dispatchers.Default) {
runBlocking { async { heavy() }.await() } // anti-pattern
}
// AFTER: stay suspending
suspend fun after(): Int = coroutineScope {
async(Dispatchers.Default) { heavy() }.await()
}
suspend fun heavy(): Int { delay(10); return 42 }go deeper
Knows runBlocking shouldn't be used inside other coroutines because it blocks a thread.
Explains pool starvation and names withContext/coroutineScope as replacements.
Reasons through the deadlock scenario on bounded dispatchers and the broken cancellation, and refactors correctly.
Encodes 'no nested runBlocking' as a lint/detekt rule and designs dispatcher/blocking-IO boundaries across services.
## The core invariant coroutines rely on Coroutines achieve scalability because a **suspend** point releases the underlying thread back to the dispatcher; the thread can run other coroutines while this one is suspended. `runBlocking` violates that: it **occupies the carrier thread** until the nested block (and its children) complete. Nesting it inside the coroutine world reintroduces thread blocking exactly where you were trying to avoid it. ## Concrete failure modes ### 1. Thread-pool starvation `Dispatchers.Default` has roughly `numberOfCores` (min 2) threads. If several coroutines each call `runBlocking` and block their carrier threads, you can occupy the whole pool with parked threads. Other ready coroutines can't run — throughput collapses. ### 2. Deadlock Worse: if the work *inside* the nested runBlocking needs a thread from the same exhausted pool to progress (e.g. it awaits another coroutine that must be dispatched on `Default`), no thread is free to run it. Everyone waits forever. ```kotlin // ANTI-PATTERN suspend fun bad(): Int = withContext(Dispatchers.Default) { runBlocking { // blocks a Default carrier thread async { compute() }.await() // needs a Default thread to run -> risk of starvation/deadlock } } ``` ### 3. Cancellation broken Structured cancellation works by suspending at cancellation points. A thread parked inside `runBlocking` is not at a suspendable point of the *outer* coroutine, so cancelling the outer coroutine can't interrupt it promptly. ## The fixes — stay in the suspend world ```kotlin // GOOD: structured concurrency, no thread blocking suspend fun good(): Int = coroutineScope { val a = async { compute() } val b = async { compute() } a.await() + b.await() } // GOOD: switch context for a sub-task without blocking suspend fun loadFromDb(): Row = withContext(Dispatchers.IO) { blockingJdbcCall() // the ONLY blocking call is isolated to an IO thread } ``` - **`coroutineScope { }`** — suspends until structured children finish; replaces 'runBlocking to wait for children'. - **`withContext(ctx) { }`** — suspends while switching dispatcher; replaces 'runBlocking on another dispatcher'. - **`async { }.await()`** — concurrent results without blocking. - For a **legitimately blocking JDK API**, isolate it with `withContext(Dispatchers.IO)` (a large, elastic pool) — do **not** wrap the result in runBlocking. ## Rule of thumb `runBlocking` lives at the **outermost** blocking boundary only (main, a test, a non-suspend framework callback). Once you are inside a suspend function or coroutine, you already have everything you need to compose without it. ## Keywords / APIs `suspend`, `runBlocking`, `coroutineScope`, `withContext`, `async`/`await`, `Dispatchers.Default`/`IO`, carrier thread, thread starvation, deadlock, structured cancellation.
- If you must call a blocking JDBC method from a suspend function, what's the right approach?Wrap just that blocking call in withContext(Dispatchers.IO) so it runs on the elastic IO pool; do not bridge back with runBlocking.
- Why can nesting runBlocking on Dispatchers.Default deadlock?Default has ~CPU-count threads; if blocked carrier threads exhaust the pool and the nested work needs a Default thread to proceed, nothing is free to run it.
It's like one cashier (thread) freezing mid-transaction to phone another department: with only a few cashiers, the whole queue stalls — and if that department also needs a free cashier, nobody moves.
saying these in an interview costs you the question
- Treating nested runBlocking as harmless
- Suggesting a bigger Default pool instead of removing the blocking
- Not knowing coroutineScope/withContext replace runBlocking inside suspend code
- Wrapping blocking JDBC results in runBlocking rather than withContext(IO)
- Ignoring the cancellation-cooperation problem