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.
| Aspek | Thread | Coroutine |
|---|---|---|
| Memori per unit | ~1 MB stack | ~100 bytes |
| Context switch | ~5-10 μs (kernel) | ~50-100 ns (user-space) |
| Batas jumlah | Ribuan | Jutaan |
| Preemptive | Ya (OS) | Kooperatif (suspend/resume) |
| Diblokir oleh blocking call | Ya | Tidak (suspend releases thread) |
Pilihan Dispatcher #
Dispatcher menentukan thread pool yang menjalankan coroutine. Pilihan dispatcher sangat memengaruhi performance.
| Dispatcher | Thread pool | Cocok untuk |
|---|---|---|
Dispatchers.Main | 1 thread (UI) | UI thread Android/Swing |
Dispatchers.Default | CPU core count | CPU-bound: sorting, parsing, computation |
Dispatchers.IO | 64 thread (default) | I/O-bound: network, file, database |
Dispatchers.Unconfined | Caller thread | Testing, ringan |
| Custom Dispatcher | Custom pool | Resource 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:
// 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:
=== 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 samaMari kita bedah hasil pengukuran di atas:
- Sequential baseline: 5 task dieksekusi satu per satu di main coroutine. Total step = 5 × 100 = 500. Tidak ada context switch, hanya 1 coroutine.
- 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. - 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. - 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. - Thread vs Coroutine: 1000 thread membutuhkan ~1 GB stack (default 1 MB per thread), rentan OutOfMemoryError. 1000 coroutine hanya membutuhkan ~100 KB, sangat ringan.
- 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:
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.coroutinesuntuk 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.