[2026] Kotlin Coroutines vs Threads | Concurrency Model, Cost, and When to Use Which

[2026] Kotlin Coroutines vs Threads | Concurrency Model, Cost, and When to Use Which

이 글의 핵심

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. 아래 코드는 kotlin를 사용한 구현 예제입니다. 코드를 직접 실행해보면서 동작을 확인해보세요.

// 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). 아래 코드는 kotlin를 사용한 구현 예제입니다. 코드를 직접 실행해보면서 동작을 확인해보세요.

// 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

다음은 kotlin를 활용한 상세한 구현 코드입니다. 클래스를 정의하여 데이터와 기능을 캡슐화하며. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.

// 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

다음은 kotlin를 활용한 상세한 구현 코드입니다. 필요한 모듈을 import하고. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.

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

다음은 kotlin를 활용한 상세한 구현 코드입니다. 반복문으로 데이터를 처리합니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.

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

다음은 kotlin를 활용한 상세한 구현 코드입니다. 비동기 처리를 통해 효율적으로 작업을 수행합니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.

// 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

아래 코드는 kotlin를 사용한 구현 예제입니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.

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

아래 코드는 kotlin를 사용한 구현 예제입니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.

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)

다음은 kotlin를 활용한 상세한 구현 코드입니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.

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)

아래 코드는 kotlin를 사용한 구현 예제입니다. 비동기 처리를 통해 효율적으로 작업을 수행합니다. 코드를 직접 실행해보면서 동작을 확인해보세요.

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

아래 코드는 kotlin를 사용한 구현 예제입니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.

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

다음은 간단한 kotlin 코드 예제입니다. 코드를 직접 실행해보면서 동작을 확인해보세요.

// 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

아래 코드는 kotlin를 사용한 구현 예제입니다. 코드를 직접 실행해보면서 동작을 확인해보세요.

// 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

다음은 간단한 kotlin 코드 예제입니다. 코드를 직접 실행해보면서 동작을 확인해보세요.

// Android UI thread
launch(Dispatchers.Main) {
    textView.text = "Updated"
}
  • Single thread (UI thread)
  • Use for: UI updates
  • Platform-specific (Android, JavaFX, Swing)

Custom dispatcher

아래 코드는 kotlin를 사용한 구현 예제입니다. 코드를 직접 실행해보면서 동작을 확인해보세요.

val customDispatcher = Executors.newFixedThreadPool(4).asCoroutineDispatcher()
launch(customDispatcher) {
    // Custom thread pool
}

7. Real-world examples

Example 1: Parallel API calls

다음은 kotlin를 활용한 상세한 구현 코드입니다. 비동기 처리를 통해 효율적으로 작업을 수행합니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.

// 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

다음은 kotlin를 활용한 상세한 구현 코드입니다. 클래스를 정의하여 데이터와 기능을 캡슐화하며, 반복문으로 데이터를 처리합니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.

// 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

다음은 kotlin를 활용한 상세한 구현 코드입니다. 비동기 처리를 통해 효율적으로 작업을 수행합니다, 에러 처리를 통해 안정성을 확보합니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.

// 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

아래 코드는 kotlin를 사용한 구현 예제입니다. 코드를 직접 실행해보면서 동작을 확인해보세요.

// ❌ 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

다음은 kotlin를 활용한 상세한 구현 코드입니다. 클래스를 정의하여 데이터와 기능을 캡슐화하며. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.

// ❌ 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

아래 코드는 kotlin를 사용한 구현 예제입니다. 코드를 직접 실행해보면서 동작을 확인해보세요.

// ❌ 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

다음은 kotlin를 활용한 상세한 구현 코드입니다. 비동기 처리를 통해 효율적으로 작업을 수행합니다, 에러 처리를 통해 안정성을 확보합니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.

// ❌ 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

아래 코드는 code를 사용한 구현 예제입니다. 코드를 직접 실행해보면서 동작을 확인해보세요.

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

다음은 kotlin를 활용한 상세한 구현 코드입니다. 비동기 처리를 통해 효율적으로 작업을 수행합니다, 반복문으로 데이터를 처리합니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.

// 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

다음은 kotlin를 활용한 상세한 구현 코드입니다. 비동기 처리를 통해 효율적으로 작업을 수행합니다, 에러 처리를 통해 안정성을 확보합니다, 조건문으로 분기 처리를 수행합니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.

// 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

아래 코드는 kotlin를 사용한 구현 예제입니다. 비동기 처리를 통해 효율적으로 작업을 수행합니다. 코드를 직접 실행해보면서 동작을 확인해보세요.

// ✅ GOOD: Scoped
suspend fun loadUserData() = coroutineScope {
    val profile = async { fetchProfile() }
    val posts = async { fetchPosts() }
    UserData(profile.await(), posts.await())
}

2. Choose correct dispatcher

아래 코드는 kotlin를 사용한 구현 예제입니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.

// 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

아래 코드는 kotlin를 사용한 구현 예제입니다. 코드를 직접 실행해보면서 동작을 확인해보세요.

suspend fun longRunningTask() {
    repeat(1000) { i ->
        ensureActive()  // Check cancellation
        processItem(i)
    }
}

4. Use withContext for switching

아래 코드는 kotlin를 사용한 구현 예제입니다. 코드를 직접 실행해보면서 동작을 확인해보세요.

suspend fun loadData(): Data {
    val data = withContext(Dispatchers.IO) {
        database.query()
    }
    // Back to original dispatcher
    return processData(data)
}

5. Avoid GlobalScope

아래 코드는 kotlin를 사용한 구현 예제입니다. 클래스를 정의하여 데이터와 기능을 캡슐화하며. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.

// ❌ 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

... 996 lines not shown ... Token usage: 63706/1000000; 936294 remaining Start-Sleep -Seconds 3