본문으로 건너뛰기 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


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.


Other articles related to this topic.


Keywords Covered in This Article (Related Search Terms)

This article covers Kotlin, Coroutine, Thread, Async, Concurrency, Performance, Comparison.