본문으로 건너뛰기
Previous
Next
Kotlin Coroutines vs Threads | Concurrency Model· Cost

Kotlin Coroutines vs Threads | Concurrency Model· Cost

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

  1. Quick comparison
  2. How they work
  3. Performance benchmarks
  4. Memory overhead
  5. Structured concurrency
  6. Dispatchers explained
  7. Real-world examples
  8. Common mistakes
  9. Practical guide
  10. Side-by-side code
  11. Best practices

1. Quick comparison

CoroutinesThreads
WeightMany thousands+Dozens–hundreds typical
Memory~KB-scale state~MB stacks
Create costVery low (~1-2μs)Higher (~100-200μs OS)
Context switchUser-space (cheap)Kernel (heavier)
CancellationStructured + cooperativeManual / interrupt
DefaultPreferLegacy / special
SchedulingDispatchers (thread pools)OS scheduler
BlockingSuspends without blockingBlocks 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

  1. Default to coroutines on the JVM with Kotlin
  2. Threads for narrow legacy/interop cases
  3. Pick Dispatchers for I/O vs CPU
  4. Use structured concurrency for lifecycles
  5. Coroutines are 20-60x faster to create
  6. 500-1000x more memory efficient than threads

Performance summary

MetricThreadsCoroutinesWinner
Creation~100-200μs~1-2μsCoroutines (100x)
Memory~1MB~1-2KBCoroutines (500x)
Context switchKernelUser-spaceCoroutines
ScalabilityHundredsMillionsCoroutines
Coroutines are not magic—they organize async work safely on top of threads.


Keywords

Kotlin, coroutine, thread, async, concurrency, Dispatchers, structured concurrency, comparison, performance


자주 묻는 질문 (FAQ)

Q. 이 내용을 실무에서 언제 쓰나요?

A. Complete comparison of Kotlin coroutines and OS threads: lightweight concurrency, memory overhead, scheduling, structure… 실무에서는 위 본문의 예제와 선택 가이드를 참고해 적용하면 됩니다.

Q. 선행으로 읽으면 좋은 글은?

A. 각 글 하단의 이전 글 또는 관련 글 링크를 따라가면 순서대로 배울 수 있습니다. C++ 시리즈 목차에서 전체 흐름을 확인할 수 있습니다.

Q. 더 깊이 공부하려면?

A. cppreference와 해당 라이브러리 공식 문서를 참고하세요. 글 말미의 참고 자료 링크도 활용하면 좋습니다.


같이 보면 좋은 글 (내부 링크)

이 주제와 연결되는 다른 글입니다.


이 글에서 다루는 키워드 (관련 검색어)

Kotlin, Coroutine, Thread, Async, Concurrency, Performance, Comparison 등으로 검색하시면 이 글이 도움이 됩니다.