Why Blocking vs Non‑Blocking in Kotlin Coroutines Matters: Live Experiments
This article demystifies the difference between blocking and non‑blocking coroutine work in Kotlin by providing runnable examples, measuring sequential, concurrent, and asynchronous execution, and revealing how thread usage, jobs, and dispatchers affect performance and true parallelism.
Opening: Clarify "concurrency/async" and "blocking/non‑blocking" in coroutines
Many who use coroutines are confused about when something is "concurrent" versus "asynchronous" and why code using suspend can still appear to block. The following runnable examples illustrate these concepts clearly.
Note: Strictly speaking, coroutines run on a thread pool, not a specific thread. For simplicity, we use "thread" as an analogy.
Blocking work: thread busy
suspend fun workOnBlockingTask(time: Long) {
Thread.sleep(time)
yield()
} Thread.sleep()simulates a CPU‑intensive task (e.g., sorting a large array). The thread is occupied and cannot do other work. yield() creates a suspension point, allowing other coroutines on the same thread to run; without it, the current coroutine would monopolize the thread.
Non‑blocking work: thread idle
suspend fun workOnNonBlockingTask(time: Long) {
delay(time)
} delay()suspends the coroutine and releases the thread, similar to making a network request and letting the thread handle other tasks while waiting.
Four actions: two blocking vs two non‑blocking
// 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, B represent CPU‑intensive tasks; C, D represent I/O‑waiting tasks.
Different timings are chosen to 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 blocking and non‑blocking groups run sequentially, taking roughly 1400 ms, establishing a baseline.
Experiment 2: Concurrent execution
Using launch to run actions concurrently:
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")Results:
Blocking: still around 1400 ms. Although "concurrent", the coroutines cooperate on the same thread, yielding to each other – not true parallelism.
Non‑blocking: time drops to about 800 ms because both coroutines suspend and let the thread handle other work while waiting.
Experiment 3: Asynchronous execution with a thread pool
Specifying Dispatchers.Default moves coroutines to the default thread pool:
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 finish in ~800 ms because multiple threads truly run in parallel, regardless of blocking or non‑blocking work.
Digging deeper: What really happens?
Adding debug prints at the start of each action reveals job, dispatcher, and thread information:
println("A >> Job: ${coroutineContext[Job]}")
println("A >> Dispatcher: ${coroutineContext[ContinuationInterceptor]}")
println("A >> Thread: ${Thread.currentThread().name}")Sequential execution: all prints show the main thread and a shared runBlocking job/dispatcher.
Concurrent execution: still on the main thread, but each launch gets its own job while sharing the dispatcher.
Asynchronous execution: thread names become DefaultDispatcher-worker-*, indicating different worker threads and true parallelism.
Key conclusions
The essence of blocking vs non‑blocking is whether the work releases the thread; delay releases, sleep does not.
Concurrency vs asynchrony (closer to parallelism): the former is cooperative single‑threaded switching; the latter runs on multiple threads simultaneously.
The best way to understand is to run the code yourself, tweak timings, change dispatchers, and mix blocking with non‑blocking tasks.
Printing debug information (thread name, job) is a useful microscope for observing behavior changes.
AndroidPub
Senior Android Developer & Interviewer, regularly sharing original tech articles, learning resources, and practical interview guides. Welcome to follow and contribute!
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.
