Bab penutup section Coroutines ini merangkum aturan praktis yang harus selalu diingat saat menulis kode coroutine. Aturan-aturan ini bukan sekadar gaya penulisan — mereka mencegah bug sulit yang umum muncul di production: job yang bocor, thread yang terblokir, pembatalan yang tidak berjalan, hingga race condition. Kita akan melihat setiap aturan disertai contoh kode konkret yang dapat diverifikasi.

Aturan 1: Pakai Structured Concurrency #

Structured concurrency berarti: setiap coroutine harus terikat ke sebuah CoroutineScope dengan lifetime yang jelas. Jangan pakai GlobalScope di kode produksi — job yang berjalan di GlobalScope tidak ikut dibatalkan saat pemanggilnya berhenti, sehingga resource bisa bocor.

kotlin
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
import java.util.concurrent.atomic.AtomicInteger

// ===== ATURAN 1: Structured concurrency =====
// JANGAN: pakai GlobalScope — job bocor ke luar pemanggil
// BENAR: pakai scope eksplisit (coroutineScope, scope injeksi)

class Repository(private val scope: CoroutineScope) {
    // suspend function: tidak perlu scope eksplisit — pakai coroutineScope internal
    suspend fun ambilSebagian(ids: List<Int>): List<String> = coroutineScope {
        ids.map { async { ambilSatu(it) } }.awaitAll()
    }

    private suspend fun ambilSatu(id: Int): String {
        delay(30)
        return "item-$id"
    }

    // Mengembalikan Flow untuk stream: lazy, dibatalkan saat collect berhenti
    fun streamData(): Flow<String> = flow {
        repeat(3) { i ->
            delay(20)
            emit("nilai-$i")
        }
    }.flowOn(Dispatchers.IO)
}

// ===== ATURAN 2: withContext untuk pindah dispatcher =====

class LayananIO {
    suspend fun bacaFile(): String = withContext(Dispatchers.IO) {
        delay(40)  // simulasi baca file
        "isi-dari-disk"
    }

    suspend fun hitungBerat(): Int = withContext(Dispatchers.Default) {
        var total = 0
        repeat(1_000) { total += it }
        total
    }
}

// ===== ATURAN 3: Cancellation-aware code =====

suspend fun prosesDenganResource(path: String): String {
    var lineDibaca = 0
    try {
        delay(30)  // simulasi operasi async
        lineDibaca = 42
        return "diproses-$path-$lineDibaca"
    } catch (e: CancellationException) {
        println("[proses] dibatalkan; baris dibaca = $lineDibaca")
        throw e  // WAJIB lempar ulang
    }
}

// ===== ATURAN 4: Nonce atomik untuk pengujian konkurensi =====
// AtomicInteger aman dipakai dari beberapa thread tanpa sinkronisasi manual

val counter = AtomicInteger(0)

fun main() = runBlocking {
    println("=== 1. Structured concurrency: repository.ambilSebagian ===")
    val repo = Repository(this)
    val hasil = repo.ambilSebagian(listOf(1, 2, 3))
    println("hasil = $hasil")
    assert(hasil == listOf("item-1", "item-2", "item-3"))
    println("assert lolos: structured concurrency mengembalikan urutan sama")

    println()
    println("=== 2. withContext: pindah dispatcher untuk IO dan CPU ===")
    val io = LayananIO()
    val (file, total) = io.bacaFile() to io.hitungBerat()
    println("file = $file, total = $total")
    assert(file == "isi-dari-disk")
    assert(total == 499500)  // 0+1+2+...+999
    println("assert lolos: withContext mengganti dispatcher dan mengembalikan hasil")

    println()
    println("=== 3. Cancellation-aware: try/catch + lempar ulang ===")
    val job = launch {
        try {
            prosesDenganResource("file-A")
            delay(1000)  // jika tidak dibatalkan, akan jalan lama
        } catch (e: CancellationException) {
            println("[main-launch] dibatalkan dengan rapi: ${e::class.simpleName}")
        }
    }
    delay(15)
    job.cancelAndJoin()
    println("assert lolos: prosesDenganResource menangani pembatalan")

    println()
    println("=== 4. Flow untuk stream: lazy dan dibatalkan saat collect berhenti ===")
    val repoFlow = Repository(this)
    val tangkapan = mutableListOf<String>()
    val flowJob = launch {
        repoFlow.streamData().collect { tangkapan.add(it) }
    }
    delay(50)  // biarkan beberapa emit terjadi
    flowJob.cancel()
    println("tangkapan (sebelum cancel) = $tangkapan")
    assert(tangkapan.size >= 1 && tangkapan.size <= 3)
    println("assert lolos: Flow dibatalkan saat job collector dibatalkan")

    println()
    println("=== 5. AtomicInteger: konkurensi aman tanpa race condition ===")
    counter.set(0)
    val jobList = (1..10).map {
        launch(Dispatchers.Default) {
            repeat(100) {
                counter.incrementAndGet()
            }
        }
    }
    jobList.joinAll()
    println("counter akhir = ${counter.get()} (10 * 100 = 1000)")
    assert(counter.get() == 1000)
    println("assert lolos: AtomicInteger menjaga konsistensi konkurensi")

    println()
    println("SEMUA PRAKTIK BAIK LOLOS VERIFIKASI")
}

Output yang Dihasilkan:

text
=== 1. Structured concurrency: repository.ambilSebagian ===
hasil = [item-1, item-2, item-3]
assert lolos: structured concurrency mengembalikan urutan sama

=== 2. withContext: pindah dispatcher untuk IO dan CPU ===
file = isi-dari-disk, total = 499500
assert lolos: withContext mengganti dispatcher dan mengembalikan hasil

=== 3. Cancellation-aware: try/catch + lempar ulang ===
[proses] dibatalkan; baris dibaca = 0
[main-launch] dibatalkan dengan rapi: JobCancellationException
assert lolos: prosesDenganResource menangani pembatalan

=== 4. Flow untuk stream: lazy dan dibatalkan saat collect berhenti ===
tangkapan (sebelum cancel) = [nilai-0]
assert lolos: Flow dibatalkan saat job collector dibatalkan

=== 5. AtomicInteger: konkurensi aman tanpa race condition ===
counter akhir = 1000 (10 * 100 = 1000)
assert lolos: AtomicInteger menjaga konsistensi konkurensi

SEMUA PRAKTIK BAIK LOLOS VERIFIKASI

Mari kita bedah aturan-aturan di atas:

  1. Aturan 1 (Structured concurrency): Repository menerima CoroutineScope sebagai parameter — pattern scope injection. ambilSebagian adalah suspend function yang menggunakan coroutineScope { } internal, sehingga tidak butuh scope eksplisit. awaitAll mempertahankan urutan hasil. Job yang dibuat di dalam coroutineScope otomatis dibatalkan saat scope berhenti.
  2. Aturan 2 (withContext untuk ganti dispatcher): bacaFile() memakai withContext(Dispatchers.IO) agar operasi blocking tidak mengganggu thread pemanggil. hitungBerat() memakai Dispatchers.Default untuk komputasi CPU. Pola ini lebih disukai daripada launch(Dispatchers.IO) { } karena withContext mengembalikan nilai langsung dan menghormati structured concurrency.
  3. Aturan 3 (Cancellation-aware): prosesDenganResource menangkap CancellationException, melakukan cleanup (mencetak baris yang sudah dibaca), lalu melempar ulang. Aturan krusial: jangan menelan CancellationException — structured concurrency bergantung pada propagasi exception ini untuk membatalkan job dengan benar.
  4. Aturan 4 (Flow untuk stream): streamData() mengembalikan Flow<String> — bukan Job atau Deferred<List<String>>. Flow bersifat lazy: tidak ada komputasi sampai di-collect. Saat collector dibatalkan, upstream Flow otomatis berhenti. Output Tes 4 menunjukkan tangkapan berisi [nilai-0] — hanya satu emit sempat diproses sebelum cancel().
  5. Aturan 5 (Konkurensi aman): AtomicInteger digunakan untuk berbagi counter antar coroutine. 10 coroutine × 100 increment = 1000. Tanpa atomic (mis. pakai var counter: Int biasa), race condition akan menyebabkan hasil kurang dari 1000.

Aturan Tambahan yang Sering Dilupakan #

Beberapa aturan praktis lain yang patut selalu diingat:

AturanKonsekuensi bila dilanggar
Buat fungsi suspend, bukan kembalikan JobPemanggil tidak bisa await; job bocor
Jangan blokir thread dengan Thread.sleep di suspend functionThread dispatcher terkunci; konkurensi rusak
Gunakan flowOn untuk Flow yang melakukan I/OThread pemanggil (mis. UI) terblokir
Hindari Dispatchers.Unconfined di produksiPerilaku tidak terduga saat ada delay atau yield
Tangkap exception di CoroutineExceptionHandler atau try/catch di sekitar awaitException bocor ke thread uncaught handler
Pakai SupervisorJob hanya saat child tidak saling membatalkanSatu child gagal → semua child lain dibatalkan

Pola: Fungsi Suspend, Bukan Mengembalikan Job #

kotlin Buruk: kembalikan Job
// Pemanggil tidak tahu kapan selesai, atau harus join manual
fun ambilData(id: Int): Job = scope.launch { ... }
kotlin Baik: suspend function
// Pemanggil menunggu secara alami; hasil dikembalikan langsung
suspend fun ambilData(id: Int): String {
    delay(30)
    return "data-$id"
}

Pola suspend function lebih idiomatik karena pemanggil bisa try/catch, menggabungkan beberapa pemanggilan dengan async, dan tidak perlu khawatir job bocor. Job sebagai nilai kembalian hanya cocok untuk fire-and-forget yang sangat spesifik (mis. UI toast).

Pola: Berbagi State dengan Channel atau StateFlow #

Untuk berbagi state yang sering berubah antar coroutine, jangan pakai var + Mutex secara manual — gunakan StateFlow:

kotlin StateFlow sebagai state yang aman
class CounterViewModel {
    private val _count = MutableStateFlow(0)
    val count: StateFlow<Int> = _count.asStateFlow()

    fun increment() {
        _count.value = _count.value + 1  // aman dari race condition
    }
}

StateFlow menjamin atomicity untuk single-write, dan otomatis memancarkan nilai terbaru ke semua collector.

Referensi Lanjutan #

Penutup Section Coroutines #

Section ini telah membawa kita dari konsep dasar asynchronous programming hingga praktik terbaik penulisan coroutine. Kita memahami builder (launch, async, runBlocking), structured concurrency, dispatcher, exception handling, hingga Flow dan StateFlow. Bab-bab selanjutnya di Course Kotlin akan beralih ke topik baru: section File I/O dimulai dengan file fundamentals — bagaimana Kotlin memanfaatkan java.io.File dan java.nio.file.Path untuk operasi file yang ringkas dan aman.