Why Does suspend Still Feel Blocking? Unraveling Concurrency in Kotlin Coroutines
This article demystifies the differences between concurrency, asynchrony, blocking and non‑blocking in Kotlin coroutines by providing runnable examples, experiments, and detailed analysis of thread usage, dispatcher behavior, and performance outcomes.
Opening: Clarify “Concurrency/Asynchrony, Blocking/Non‑Blocking” in Coroutines
Many coroutine users are confused about when something is concurrent vs asynchronous and why a suspend function can still appear blocking. The article provides runnable examples to illustrate the differences.
Note: Strictly speaking, coroutines run on a thread pool, not a specific thread; the article uses “thread” as an analogy.
Blocking work: thread busy
suspend fun workOnBlockingTask(time: Long) {
Thread.sleep(time)
yield()
} Thread.sleep()simulates CPU‑intensive work; the thread is occupied. yield() creates a suspension point so other coroutines on the same thread can run.
Non‑blocking work: thread idle
suspend fun workOnNonBlockingTask(time: Long) {
delay(time)
} delay()suspends the coroutine and releases the thread, similar to waiting for I/O.
Four actions: two blocking and two non‑blocking
Define two blocking actions (A, B) and two non‑blocking actions (C, D) to compare.
// Blocking
suspend fun actionA() {
println("A >> STARTED")
workOnBlockingTask(500)
println("A >> Step 1 done")
workOnBlockingTask(300)
println("A >> Step 2 done")
println("A >> DONE")
}
suspend fun actionB() {
println("B >> Started")
workOnBlockingTask(200)
println("B >> Step 1 done")
workOnBlockingTask(400)
println("B >> Step 2 done")
println("B >> DONE")
}
// Non‑blocking
suspend fun actionC() {
println("C >> Started")
workOnNonBlockingTask(500)
println("C >> Step 1 done")
workOnNonBlockingTask(300)
println("C >> Step 2 done")
println("C >> DONE")
}
suspend fun actionD() {
println("D >> Started")
workOnNonBlockingTask(200)
println("D >> Step 1 done")
workOnNonBlockingTask(400)
println("D >> Step 2 done")
println("D >> DONE")
}A and B represent CPU‑bound tasks; C and D represent I/O‑wait tasks.
Different timings make the interleaving observable.
Experiment 1: Sequential execution (baseline)
val blockingTime = measureTimeMillis {
runBlocking {
actionA()
actionB()
}
}
println("Blocking operations: $blockingTime ms")
val nonBlockingTime = measureTimeMillis {
runBlocking {
actionC()
actionD()
}
}
println("Non‑blocking operations: $nonBlockingTime ms")Both groups run sequentially, taking about 1400 ms, providing a baseline.
Experiment 2: Concurrent execution
Launch the actions concurrently with launch:
val blockingTime = measureTimeMillis {
runBlocking {
launch { actionA() }
launch { actionB() }
}
}
println("Blocking operations: $blockingTime ms")
val nonBlockingTime = measureTimeMillis {
runBlocking {
launch { actionC() }
launch { actionD() }
}
}
println("Non‑blocking operations: $nonBlockingTime ms")Blocking tasks still take ~1400 ms because they cooperatively yield on the same thread. Non‑blocking tasks finish in ~800 ms because their delays run in parallel while the thread stays idle.
Experiment 3: Asynchronous execution on a thread pool
Run the same launches on Dispatchers.Default so they use multiple worker threads:
val blockingTime = measureTimeMillis {
runBlocking(Dispatchers.Default) {
launch { actionA() }
launch { actionB() }
}
}
println("Blocking operations: $blockingTime ms")
val nonBlockingTime = measureTimeMillis {
runBlocking(Dispatchers.Default) {
launch { actionC() }
launch { actionD() }
}
}
println("Non‑blocking operations: $nonBlockingTime ms")Both groups complete in ~800 ms, demonstrating true parallelism when multiple threads are available.
What really happens?
Adding debug prints for Job, Dispatcher, and thread name shows:
Sequential run: all coroutines share the same Job and Dispatcher inside runBlocking.
Concurrent run: each launch gets its own Job but shares the Dispatcher; still on the main thread.
Asynchronous run: coroutines run on different DefaultDispatcher‑worker‑* threads, achieving real parallel execution.
Key conclusions
Blocking vs non‑blocking depends on whether the work releases the thread ( delay releases, Thread.sleep does not).
Concurrency (cooperative multitasking) differs from asynchronous parallelism; the former rotates on a single thread, the latter runs on multiple threads.
Running and modifying the code yourself is the best way to internalize these concepts.
Debug prints of Job, Dispatcher, and thread name are useful “microscopes” for understanding behavior.
Sohu Tech Products
A knowledge-sharing platform for Sohu's technology products. As a leading Chinese internet brand with media, video, search, and gaming services and over 700 million users, Sohu continuously drives tech innovation and practice. We’ll share practical insights and tech news here.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.
