Coroutines vs Threads
Coroutines vs Threads
Kotlin Coroutines and JVM threads both enable concurrent execution, but they operate at fundamentally different levels of abstraction. Threads are OS managed constructs that consume real kernel resources, while coroutines are compiler generated state machines that multiplex onto a small pool of threads managed by dispatchers. Understanding the mechanics behind each model is essential for choosing the right concurrency tool in Android applications. By the end of this lesson, you will be able to:
- Explain the structural difference between coroutine suspension and thread blocking at the JVM level.
- Describe how coroutines achieve concurrency without occupying dedicated threads.
- Understand how the Kotlin compiler transforms suspend functions into state machines.
- Compare memory and context switching costs of coroutines versus threads.
- Identify which workload types benefit from coroutines and which require thread level parallelism.
- Apply dispatchers to bridge coroutines with thread pools for CPU bound work.
Suspension vs Blocking
A thread that calls Thread.sleep(1000) or waits on a blocking I/O operation holds its OS thread for the entire duration. The thread cannot execute anything else until the blocking call returns. The OS scheduler must context switch to another thread if work needs to proceed, and that switch involves saving and restoring CPU registers, stack pointers, and kernel structures.
A coroutine that calls delay(1000) does not hold a thread. The Kotlin compiler transforms every suspend function into a state machine. When delay is reached, the coroutine records its current state, releases the thread back to the dispatcher, and schedules a resumption after the timer expires. During that window, the same thread can execute other coroutines.
// This blocks the thread for 1 second
fun blockingWork() {
Thread.sleep(1000)
}
// This suspends the coroutine, freeing the thread
suspend fun suspendingWork() {
delay(1000)
}
The distinction matters at scale. Launching 100,000 threads would exhaust memory, since each thread allocates a stack (typically 512KB to 1MB by default on the JVM). Even if the OS provides virtual memory lazily, the scheduler overhead of managing 100,000 runnable threads is prohibitive. Launching 100,000 coroutines works because coroutines share a few threads from a pool, and each suspended coroutine consumes only a small object on the heap.
Concurrency Model and Dispatchers
Coroutines run on dispatchers that manage underlying thread pools. Dispatchers.Default backs onto a pool sized to the number of CPU cores, optimized for CPU bound computation. Dispatchers.IO uses a larger, elastic pool for blocking I/O operations. Dispatchers.Main confines execution to the Android main thread.
suspend fun loadData(): Data = withContext(Dispatchers.IO) {
// runs on a thread from the IO pool
database.query("SELECT * FROM users")
}
suspend fun processData(data: Data) = withContext(Dispatchers.Default) {
// runs on a thread from the Default pool
data.items.map { transform(it) }
}
When you use withContext, the coroutine suspends on the current dispatcher, resumes on the target dispatcher, and then returns to the original dispatcher when the block completes. This is far cheaper than creating and destroying threads, because the thread pools are reused and the coroutine state machine is a lightweight heap object.
This interview continues for subscribers
Subscribe to Dove Letter for full access to exclusive interviews about Android and Kotlin development.
Become a Sponsor