Kotlin Coroutines vs Threads | Concurrency Model· Cost
이 글의 핵심
Complete comparison of Kotlin coroutines and OS threads: lightweight concurrency, memory, scheduling, structured concurrency, Dispatchers, performance benchmarks, real-world examples, and best practices.
Introduction
“Coroutines or threads?” This article compares Kotlin coroutines and Java/OS threads and gives practical defaults.
What you will learn
- How each works (kernel vs user-level scheduling)
- Cost of creation and context switches
- Structured concurrency and cancellation
- When threads still appear
- Performance benchmarks
- Real-world examples
Table of contents
- Quick comparison
- How they work
- Performance benchmarks
- Memory overhead
- Structured concurrency
- Dispatchers explained
- Real-world examples
- Common mistakes
- Practical guide
- Side-by-side code
- Best practices
1. Quick comparison
| Coroutines | Threads | |
|---|---|---|
| Weight | Many thousands+ | Dozens–hundreds typical |
| Memory | ~KB-scale state | ~MB stacks |
| Create cost | Very low (~1-2μs) | Higher (~100-200μs OS) |
| Context switch | User-space (cheap) | Kernel (heavier) |
| Cancellation | Structured + cooperative | Manual / interrupt |
| Default | Prefer | Legacy / special |
| Scheduling | Dispatchers (thread pools) | OS scheduler |
| Blocking | Suspends without blocking | Blocks thread |
2. How they work
Threads
Threads: OS schedules, large stacks (typically 1MB), kernel transitions for context switches.
// Traditional thread
// 실행 예제
Thread {
println("Running on: ${Thread.currentThread().name}")
Thread.sleep(1000) // Blocks entire thread
println("Done")
}.start()
Characteristics:
- Scheduled by OS kernel
- Pre-emptive multitasking
- Each thread has its own stack (1MB default on Linux)
- Context switch involves kernel mode transition
Coroutines
Coroutines: suspend without blocking threads; delay frees the worker; state machines resume later—often on thread pools (Dispatchers).
// Coroutine
// 실행 예제
GlobalScope.launch {
println("Running on: ${Thread.currentThread().name}")
delay(1000) // Suspends, thread is free
println("Done")
}
Characteristics:
- Cooperative multitasking
- Suspend functions release thread
- State machine transformation by compiler
- Resumed on dispatcher thread pool
Under the hood
// This coroutine code:
suspend fun example() {
val result1 = fetchData()
val result2 = processData(result1)
return result2
}
// Becomes state machine (simplified):
class ExampleStateMachine : Continuation<Unit> {
var label = 0
var result1: Data? = null
override fun resumeWith(result: Result<Any?>) {
when (label) {
0 -> {
label = 1
fetchData(this) // Pass continuation
}
1 -> {
result1 = result.getOrThrow() as Data
label = 2
processData(result1, this)
}
2 -> {
// Done
}
}
}
}
3. Performance benchmarks
Creation cost
import kotlin.system.measureTimeMillis
fun benchmarkThreads() {
val time = measureTimeMillis {
repeat(10_000) {
Thread {
// Do nothing
}.start()
}
}
println("Threads: ${time}ms") // ~2000-3000ms
}
fun benchmarkCoroutines() = runBlocking {
val time = measureTimeMillis {
repeat(10_000) {
launch {
// Do nothing
}
}
}
println("Coroutines: ${time}ms") // ~50-100ms
}
Results (typical):
- 10,000 threads: 2-3 seconds
- 10,000 coroutines: 50-100ms
- 20-60x faster for coroutines
Context switch cost
fun benchmarkThreadContextSwitch() {
val threads = List(1000) {
Thread {
repeat(1000) {
Thread.yield()
}
}
}
val time = measureTimeMillis {
threads.forEach { it.start() }
threads.forEach { it.join() }
}
println("Thread switches: ${time}ms") // ~5000-10000ms
}
fun benchmarkCoroutineContextSwitch() = runBlocking {
val time = measureTimeMillis {
repeat(1000) {
launch {
repeat(1000) {
yield()
}
}
}
}
println("Coroutine switches: ${time}ms") // ~500-1000ms
}
Concurrent I/O operations
// 100,000 concurrent HTTP requests
fun withThreads() {
val executor = Executors.newFixedThreadPool(1000) // Limited pool
val time = measureTimeMillis {
repeat(100_000) {
executor.submit {
// Simulate HTTP call
Thread.sleep(100)
}
}
executor.shutdown()
executor.awaitTermination(1, TimeUnit.HOURS)
}
println("Threads: ${time}ms") // ~10000ms (limited by pool size)
}
fun withCoroutines() = runBlocking {
val time = measureTimeMillis {
repeat(100_000) {
launch(Dispatchers.IO) {
delay(100)
}
}
}
println("Coroutines: ${time}ms") // ~100-200ms
}
4. Memory overhead
Thread memory
fun threadMemory() {
val runtime = Runtime.getRuntime()
val before = runtime.totalMemory() - runtime.freeMemory()
val threads = List(1000) {
Thread {
Thread.sleep(10000)
}.apply { start() }
}
val after = runtime.totalMemory() - runtime.freeMemory()
println("Memory per thread: ${(after - before) / 1000 / 1024}KB")
// ~1024KB (1MB) per thread
}
Coroutine memory
fun coroutineMemory() = runBlocking {
val runtime = Runtime.getRuntime()
val before = runtime.totalMemory() - runtime.freeMemory()
repeat(1000) {
launch {
delay(10000)
}
}
val after = runtime.totalMemory() - runtime.freeMemory()
println("Memory per coroutine: ${(after - before) / 1000}bytes")
// ~1-2KB per coroutine
}
Summary:
- Thread: ~1MB per thread (stack size)
- Coroutine: ~1-2KB per suspended coroutine
- 500-1000x more memory efficient
5. Structured concurrency
Without structured concurrency (threads)
fun fetchUserData(userId: String): User {
val thread1 = Thread {
// Fetch profile
}
val thread2 = Thread {
// Fetch posts
}
thread1.start()
thread2.start()
// What if one fails? How to cancel both?
// What if parent is cancelled?
thread1.join()
thread2.join()
}
With structured concurrency (coroutines)
suspend fun fetchUserData(userId: String): User = coroutineScope {
val profile = async { fetchProfile(userId) }
val posts = async { fetchPosts(userId) }
// If one fails, both are cancelled
// If parent is cancelled, both are cancelled
User(profile.await(), posts.await())
}
Cancellation propagation
val job = GlobalScope.launch {
val child1 = launch {
delay(1000)
println("Child 1")
}
val child2 = launch {
delay(2000)
println("Child 2")
}
delay(500)
}
delay(100)
job.cancel() // Cancels parent and all children
6. Dispatchers explained
Dispatchers.Default
// CPU-bound work
launch(Dispatchers.Default) {
val result = heavyComputation()
}
- Thread pool size: Number of CPU cores
- Use for: CPU-intensive tasks
- Examples: Sorting, parsing, compression
Dispatchers.IO
// I/O-bound work
launch(Dispatchers.IO) {
val data = database.query()
val response = httpClient.get(url)
}
- Thread pool size: 64 (or configured max)
- Use for: Network, disk, database
- Examples: HTTP requests, file I/O, database queries
Dispatchers.Main
// Android UI thread
launch(Dispatchers.Main) {
textView.text = "Updated"
}
- Single thread (UI thread)
- Use for: UI updates
- Platform-specific (Android, JavaFX, Swing)
Custom dispatcher
val customDispatcher = Executors.newFixedThreadPool(4).asCoroutineDispatcher()
launch(customDispatcher) {
// Custom thread pool
}
7. Real-world examples
Example 1: Parallel API calls
// With threads
fun fetchDataThreads(): Result {
val executor = Executors.newFixedThreadPool(3)
val future1 = executor.submit { api.getUsers() }
val future2 = executor.submit { api.getPosts() }
val future3 = executor.submit { api.getComments() }
val users = future1.get()
val posts = future2.get()
val comments = future3.get()
executor.shutdown()
return Result(users, posts, comments)
}
// With coroutines
suspend fun fetchDataCoroutines(): Result = coroutineScope {
val users = async { api.getUsers() }
val posts = async { api.getPosts() }
val comments = async { api.getComments() }
Result(users.await(), posts.await(), comments.await())
}
Example 2: Producer-consumer
// With threads
class ThreadProducerConsumer {
val queue = LinkedBlockingQueue<Int>()
fun start() {
Thread {
repeat(100) {
queue.put(it)
Thread.sleep(10)
}
}.start()
Thread {
while (true) {
val item = queue.take()
process(item)
}
}.start()
}
}
// With coroutines
class CoroutineProducerConsumer {
val channel = Channel<Int>()
fun start() = CoroutineScope(Dispatchers.Default).launch {
launch {
repeat(100) {
channel.send(it)
delay(10)
}
channel.close()
}
launch {
for (item in channel) {
process(item)
}
}
}
}
Example 3: Timeout handling
// With threads (complex)
fun fetchWithTimeoutThread(url: String): String? {
val future = executor.submit { httpClient.get(url) }
return try {
future.get(5, TimeUnit.SECONDS)
} catch (e: TimeoutException) {
future.cancel(true)
null
}
}
// With coroutines (simple)
suspend fun fetchWithTimeoutCoroutine(url: String): String? {
return withTimeoutOrNull(5000) {
httpClient.get(url)
}
}
8. Common mistakes
Mistake 1: Blocking in coroutine
// ❌ BAD: Blocks thread
launch(Dispatchers.Default) {
Thread.sleep(1000) // Blocks thread!
}
// ✅ GOOD: Suspends
launch(Dispatchers.Default) {
delay(1000) // Suspends, thread is free
}
Mistake 2: Using GlobalScope
// ❌ BAD: No lifecycle management
GlobalScope.launch {
// Runs forever, no cancellation
}
// ✅ GOOD: Scoped
class MyActivity : CoroutineScope {
override val coroutineContext = Dispatchers.Main + Job()
fun loadData() {
launch {
// Cancelled when activity is destroyed
}
}
fun onDestroy() {
coroutineContext.cancel()
}
}
Mistake 3: Wrong dispatcher
// ❌ BAD: CPU work on IO dispatcher
launch(Dispatchers.IO) {
val result = heavyComputation() // Wastes IO thread
}
// ✅ GOOD: Use Default for CPU work
launch(Dispatchers.Default) {
val result = heavyComputation()
}
Mistake 4: Not handling exceptions
// ❌ BAD: Exception kills coroutine silently
launch {
throw Exception("Error") // Lost!
}
// ✅ GOOD: Handle exceptions
launch {
try {
riskyOperation()
} catch (e: Exception) {
handleError(e)
}
}
// Or use CoroutineExceptionHandler
val handler = CoroutineExceptionHandler { _, exception ->
println("Caught: $exception")
}
launch(handler) {
throw Exception("Error")
}
9. Practical guide
When to use coroutines
✅ Use coroutines for:
- Network / DB (
Dispatchers.IO) - Parallel
async/await - Composable concurrency with clear scopes
- Android UI operations
- Sequential async operations
- Thousands of concurrent tasks
When to use threads
⚠️ Threads may remain for:
- Legacy Java pools
- Blocking code you cannot wrap yet
- Interop constraints
- Libraries that require ExecutorService
- Very simple one-off tasks
Decision flowchart
Need concurrency?
├─ Yes
│ ├─ Kotlin project?
│ │ ├─ Yes → Use coroutines
│ │ └─ No → Use threads/executors
│ └─ Legacy Java?
│ └─ Use threads/executors
└─ No → Sequential code
10. Side-by-side code
Fetching multiple URLs
// Threads
fun fetchUrlsThreads(urls: List<String>): List<String> {
val executor = Executors.newFixedThreadPool(10)
val futures = urls.map { url ->
executor.submit<String> {
httpClient.get(url)
}
}
val results = futures.map { it.get() }
executor.shutdown()
return results
}
// Coroutines
suspend fun fetchUrlsCoroutines(urls: List<String>): List<String> {
return coroutineScope {
urls.map { url ->
async(Dispatchers.IO) {
httpClient.get(url)
}
}.awaitAll()
}
}
Retry logic
// Threads (complex)
fun retryThread(maxAttempts: Int, block: () -> String): String {
repeat(maxAttempts) { attempt ->
try {
return block()
} catch (e: Exception) {
if (attempt == maxAttempts - 1) throw e
Thread.sleep(1000 * (attempt + 1))
}
}
throw IllegalStateException()
}
// Coroutines (simple)
suspend fun retryCoroutine(maxAttempts: Int, block: suspend () -> String): String {
repeat(maxAttempts) { attempt ->
try {
return block()
} catch (e: Exception) {
if (attempt == maxAttempts - 1) throw e
delay(1000 * (attempt + 1))
}
}
throw IllegalStateException()
}
11. Best practices
1. Use structured concurrency
// ✅ GOOD: Scoped
suspend fun loadUserData() = coroutineScope {
val profile = async { fetchProfile() }
val posts = async { fetchPosts() }
UserData(profile.await(), posts.await())
}
2. Choose correct dispatcher
// CPU-bound
launch(Dispatchers.Default) {
val result = complexCalculation()
}
// I/O-bound
launch(Dispatchers.IO) {
val data = database.query()
}
// UI updates
launch(Dispatchers.Main) {
updateUI(data)
}
3. Handle cancellation
suspend fun longRunningTask() {
repeat(1000) { i ->
ensureActive() // Check cancellation
processItem(i)
}
}
4. Use withContext for switching
suspend fun loadData(): Data {
val data = withContext(Dispatchers.IO) {
database.query()
}
// Back to original dispatcher
return processData(data)
}
5. Avoid GlobalScope
// ❌ BAD
GlobalScope.launch { }
// ✅ GOOD
class MyViewModel : ViewModel() {
fun loadData() {
viewModelScope.launch {
// Cancelled when ViewModel is cleared
}
}
}
Summary
Key takeaways
- Default to coroutines on the JVM with Kotlin
- Threads for narrow legacy/interop cases
- Pick Dispatchers for I/O vs CPU
- Use structured concurrency for lifecycles
- Coroutines are 20-60x faster to create
- 500-1000x more memory efficient than threads
Performance summary
| Metric | Threads | Coroutines | Winner |
|---|---|---|---|
| Creation | ~100-200μs | ~1-2μs | Coroutines (100x) |
| Memory | ~1MB | ~1-2KB | Coroutines (500x) |
| Context switch | Kernel | User-space | Coroutines |
| Scalability | Hundreds | Millions | Coroutines |
| Coroutines are not magic—they organize async work safely on top of threads. |
Related posts
Keywords
Kotlin, coroutine, thread, async, concurrency, Dispatchers, structured concurrency, comparison, performance
Frequently Asked Questions (FAQ)
Q. When would I use this in practice?
A. Complete comparison of Kotlin coroutines and OS threads: lightweight concurrency, memory overhead, scheduling.
Q. What should I read before this?
A. Follow the previous article or related articles links at the bottom of each post to learn in sequence.
Q. Where can I study this more deeply?
A. Check cppreference and the relevant library’s official documentation. The reference links at the end of the article are also worth using.
Related Articles (Internal Links)
Other articles related to this topic.
- Kotlin 코루틴 | 비동기 프로그래밍 완벽 가이드
- Kotlin 코루틴 vs 스레드 완벽 비교 | 비동기 처리 선택 가이드
- Kotlin Coroutine: Channel vs Flow, 언제 쓰나 | 비교 실전 가이드
Keywords Covered in This Article (Related Search Terms)
This article covers Kotlin, Coroutine, Thread, Async, Concurrency, Performance, Comparison.