Coroutine adalah fitur andalan Kotlin untuk concurrency. Berbeda dari thread, coroutine sangat ringan: satu thread bisa menampung ribuan coroutine tanpa kehabisan memori. Namun, performance tidak otomatis baik bila dispatcher salah dipilih. CPU-bound task di Dispatchers.IO memboroskan thread pool yang besar, sementara I/O blocking di Dispatchers.Default mengunci thread CPU yang berharga. Pada bab ini kita akan membandingkan thread vs coroutine, mendemokan pilihan dispatcher yang tepat, dan menjelaskan overhead context switch.

Thread vs Coroutine #

Thread adalah unit penjadwalan OS. Setiap thread mengalokasikan stack 1 MB secara default, sehingga 1000 thread membutuhkan 1 GB memori stack saja. Context switch antar thread melibatkan kernel call yang relatif lambat (~5-10 μs).

Coroutine adalah unit penjadwalan user-space yang dibangun di atas thread. Setiap coroutine hanya memerlukan ~100 bytes untuk continuation, sehingga 1000 coroutine hanya memerlukan ~100 KB. Context switch antar coroutine tidak melibatkan kernel — hanya perubahan pointer.

AspekThreadCoroutine
Memori per unit~1 MB stack~100 bytes
Context switch~5-10 μs (kernel)~50-100 ns (user-space)
Batas jumlahRibuanJutaan
PreemptiveYa (OS)Kooperatif (suspend/resume)
Diblokir oleh blocking callYaTidak (suspend releases thread)

Pilihan Dispatcher #

Dispatcher menentukan thread pool yang menjalankan coroutine. Pilihan dispatcher sangat memengaruhi performance.

DispatcherThread poolCocok untuk
Dispatchers.Main1 thread (UI)UI thread Android/Swing
Dispatchers.DefaultCPU core countCPU-bound: sorting, parsing, computation
Dispatchers.IO64 thread (default)I/O-bound: network, file, database
Dispatchers.UnconfinedCaller threadTesting, ringan
Custom DispatcherCustom poolResource isolation

Dispatchers.Default memakai thread pool sejumlah core CPU. Bila mesin punya 8 core, pool ini punya 8 thread. Bila semua thread sibuk dengan CPU-bound task, menambah coroutine baru tidak akan mempercepat — coroutine menunggu di antrian.

Dispatchers.IO memakai thread pool yang lebih besar (default 64) karena I/O operation memblok thread tetapi tidak menggunakan CPU. Bila 64 thread semua menunggu network, CPU tetap idle dan bisa mengerjakan coroutine lain di Default.

Demo Step-Counting #

Cuplikan berikut membandingkan beberapa pola coroutine dengan step counter. Sequential baseline, parallel Default, parallel IO, dan context switch demo:

kotlin
// Demo coroutine performance: perbandingan thread vs coroutine,
// pilihan dispatcher, dan dampak context switch. Deterministik dengan step counter.

import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext

class PerfCounter {
    var totalCoroutines: Int = 0
    var totalDispatchersSwitches: Int = 0
    var totalSteps: Int = 0

    fun step(n: Int = 1) { totalSteps += n }
    fun switch() { totalDispatchersSwitches++ }
    fun coroutine() { totalCoroutines++ }
    fun report(label: String): String {
        return "  %-30s coroutines=%-5d switches=%-5d steps=%-7d".format(
            label, totalCoroutines, totalDispatchersSwitches, totalSteps
        )
    }
    fun reset() { totalCoroutines = 0; totalDispatchersSwitches = 0; totalSteps = 0 }
}

suspend fun cpuTask(n: Int, counter: PerfCounter): Int {
    var acc = 0
    for (i in 1..100) acc += n * i
    counter.step(100)
    return acc
}

suspend fun ioTask(n: Int, counter: PerfCounter): Int {
    delay(10) // simulasi I/O
    counter.step(1)
    return n * 2
}

fun main() = runBlocking {
    println("=== Coroutine Performance ===")
    println()
    println("Membandingkan thread vs coroutine, dan pilihan dispatcher.")
    println()

    val counter = PerfCounter()

    // === 1. Sequential (baseline) ===
    println("=== 1. Sequential (baseline, 1 coroutine) ===")
    counter.reset()
    val seqResults = (1..5).map { cpuTask(it, counter) }
    println(counter.report("Sequential CPU tasks"))
    println("    Hasil: ${seqResults.sum()}")
    println()

    // === 2. Parallel dengan Dispatchers.Default ===
    println("=== 2. Parallel Dispatchers.Default (CPU-bound, 5 coroutines) ===")
    counter.reset()
    val defaultResults = coroutineScope {
        (1..5).map { n ->
            counter.coroutine()
            async(Dispatchers.Default) { cpuTask(n, counter) }
        }.awaitAll()
    }
    println(counter.report("Parallel Default (5 coroutines)"))
    println("    Hasil: ${defaultResults.sum()}")
    println()

    // === 3. I/O-bound dengan Dispatchers.IO ===
    println("=== 3. I/O-bound Dispatchers.IO (5 coroutines, delay 10ms) ===")
    counter.reset()
    val ioResults = coroutineScope {
        (1..5).map { n ->
            counter.coroutine()
            async(Dispatchers.IO) { ioTask(n, counter) }
        }.awaitAll()
    }
    println(counter.report("Parallel IO (5 coroutines)"))
    println("    Hasil: ${ioResults.sum()}")
    println()

    // === 4. Context switch demo ===
    println("=== 4. Context Switch Demo ===")
    counter.reset()
    val switchResult = coroutineScope {
        (1..3).map { n ->
            async(Dispatchers.Default) {
                counter.coroutine()
                withContext(Dispatchers.IO) {
                    counter.switch()
                    ioTask(n, counter)
                }
            }
        }.awaitAll()
    }
    println(counter.report("Default → IO switch (3 coroutines)"))
    println("    Hasil: ${switchResult.sum()}")
    println("    Pelajaran: setiap withContext = 1 switch (overhead ~1-5 us)")
    println()

    // === 5. Thread vs Coroutine ===
    println("=== 5. Thread vs Coroutine (overhead) ===")
    println("  Thread: ~1 MB stack per thread, OS scheduling, context switch ~5-10 us")
    println("  Coroutine: ~100 bytes per continuation, user-space scheduling")
    println()
    println("  Skenario: spawn 1000 task")
    println("    1000 thread → 1 GB memori stack, mungkin OutOfMemoryError")
    println("    1000 coroutine → ~100 KB total, ringan")
    println()

    // === 6. Tabel pilihan dispatcher ===
    println("=== 6. Pilihan Dispatcher ===")
    val dispatchers = listOf(
        Triple("Dispatchers.Main", "1 thread (UI)", "UI thread Android/Swing"),
        Triple("Dispatchers.Default", "CPU core count", "CPU-bound: sorting, parsing"),
        Triple("Dispatchers.IO", "64 thread (default)", "I/O-bound: network, file, DB"),
        Triple("Dispatchers.Unconfined", "Caller thread", "Testing, ringan"),
        Triple("Custom Dispatcher", "Custom pool", "Resource isolation")
    )
    dispatchers.forEach { (nama, pool, cocok) ->
        println("  %-25s pool: %-20s cocok: $cocok".format(nama, pool))
    }
    println()

    // === 7. Aturan optimasi ===
    println("=== 7. Aturan Optimasi ===")
    val rules = listOf(
        "1. CPU-bound → Dispatchers.Default (jangan pakai IO)",
        "2. I/O-bound blocking → Dispatchers.IO (jangan pakai Default)",
        "3. UI update → Dispatchers.Main (Android: UI thread)",
        "4. Hindari context switch tidak perlu (withContext beruntun)",
        "5. Pakai Flow, bukan callback, untuk stream event",
        "6. Coroutine ringan — bisa spawn ribuan tanpa OutOfMemoryError",
        "7. suspend function tidak memblok thread — tetap di dispatcher yang sama"
    )
    rules.forEach { println("  $it") }
}

Output yang Dihasilkan:

text
=== Coroutine Performance ===

Membandingkan thread vs coroutine, dan pilihan dispatcher.

=== 1. Sequential (baseline, 1 coroutine) ===
  Sequential CPU tasks           coroutines=0     switches=0     steps=500    
    Hasil: 75750

=== 2. Parallel Dispatchers.Default (CPU-bound, 5 coroutines) ===
  Parallel Default (5 coroutines) coroutines=5     switches=0     steps=500    
    Hasil: 75750

=== 3. I/O-bound Dispatchers.IO (5 coroutines, delay 10ms) ===
  Parallel IO (5 coroutines)     coroutines=5     switches=0     steps=5      
    Hasil: 30

=== 4. Context Switch Demo ===
  Default → IO switch (3 coroutines) coroutines=3     switches=3     steps=3      
    Hasil: 12
    Pelajaran: setiap withContext = 1 switch (overhead ~1-5 us)

=== 5. Thread vs Coroutine (overhead) ===
  Thread: ~1 MB stack per thread, OS scheduling, context switch ~5-10 us
  Coroutine: ~100 bytes per continuation, user-space scheduling

  Skenario: spawn 1000 task
    1000 thread → 1 GB memori stack, mungkin OutOfMemoryError
    1000 coroutine → ~100 KB total, ringan

=== 6. Pilihan Dispatcher ===
  Dispatchers.Main          pool: 1 thread (UI)        cocok: UI thread Android/Swing
  Dispatchers.Default       pool: CPU core count       cocok: CPU-bound: sorting, parsing
  Dispatchers.IO            pool: 64 thread (default)  cocok: I/O-bound: network, file, DB
  Dispatchers.Unconfined    pool: Caller thread        cocok: Testing, ringan
  Custom Dispatcher         pool: Custom pool          cocok: Resource isolation

=== 7. Aturan Optimasi ===
  1. CPU-bound → Dispatchers.Default (jangan pakai IO)
  2. I/O-bound blocking → Dispatchers.IO (jangan pakai Default)
  3. UI update → Dispatchers.Main (Android: UI thread)
  4. Hindari context switch tidak perlu (withContext beruntun)
  5. Pakai Flow, bukan callback, untuk stream event
  6. Coroutine ringan — bisa spawn ribuan tanpa OutOfMemoryError
  7. suspend function tidak memblok thread — tetap di dispatcher yang sama

Mari kita bedah hasil pengukuran di atas:

  1. Sequential baseline: 5 task dieksekusi satu per satu di main coroutine. Total step = 5 × 100 = 500. Tidak ada context switch, hanya 1 coroutine.
  2. Parallel Default: 5 coroutine dijalankan paralel di Dispatchers.Default. Total step sama (500), tetapi wall-clock time sekitar 1/5 dari sequential (tergantung jumlah core). Tidak ada context switch karena tidak ada withContext.
  3. Parallel IO: 5 coroutine di Dispatchers.IO. Setiap coroutine memanggil delay(10) yang mensimulasikan I/O blocking. Karena IO pool punya 64 thread, kelima coroutine berjalan paralel tanpa antri.
  4. Context switch demo: setiap withContext(Dispatchers.IO) dari Default menghasilkan satu switch. Untuk 3 coroutine, ada 3 switch. Setiap switch memiliki overhead ~1-5 μs.
  5. Thread vs Coroutine: 1000 thread membutuhkan ~1 GB stack (default 1 MB per thread), rentan OutOfMemoryError. 1000 coroutine hanya membutuhkan ~100 KB, sangat ringan.
  6. Dispatcher table: pemilihan dispatcher sangat memengaruhi apakah pool akan jenuh atau idle. IO pool (64 thread) terlalu besar untuk CPU-bound — thread akan idle menunggu CPU.

Avoid Unnecessary Context Switches #

Context switch antar dispatcher memiliki overhead. Walaupun kecil (~1-5 μs), bila terjadi dalam loop yang ketat, akumulasi bisa signifikan. Pola umum yang salah adalah membungkus seluruh function dengan withContext:

kotlin Pola yang menyebabkan switch berlebih
suspend fun prosesA(data: List<Int>): Int = withContext(Dispatchers.Default) {
    data.sum()
}

suspend fun prosesB(data: List<Int>): Int = withContext(Dispatchers.Default) {
    prosesA(data) + 1  // dengan denganContext di dalam dengan denganContext
}

Bila caller sudah di Dispatchers.Default, withContext(Dispatchers.Default) di dalam prosesA menjadi no-op (coroutine library mendeteksi dispatcher sama dan tidak switch). Namun, bila caller tidak di Default, ada satu switch per call. Pola yang lebih baik: tandai suspend fun tanpa withContext, biarkan caller memilih dispatcher.

Referensi Lanjutan #

  • Coroutines overview — ringkasan resmi kotlinx.coroutines untuk pemrograman asinkron yang ringan

Selanjutnya #

Coroutine ringan dan cepat, tetapi setiap objek yang dialokasikan tetap memengaruhi memori. Bab berikutnya membahas memory usage: object header overhead, primitive vs boxed, dan IntArray vs Array<Int>. Materi tersebut ada di chapter Memory Usage.