Konkurensi (concurrency) adalah kemampuan program mengelola banyak pekerjaan yang berjalan bersamaan. Kotlin menawarkan dua model untuk itu, yaitu thread dari sistem operasi dan coroutine dari library kotlinx.coroutines. Topik ini menguraikan perbedaan kedua model tersebut, membuktikan ringannya coroutine dengan program yang meluncurkan sepuluh ribu coroutine sekaligus, lalu menutup dengan perbandingan sifat keduanya dalam satu tabel.
Thread sebagai unit eksekusi yang berat#
Thread adalah unit eksekusi terkecil yang dijadwalkan langsung oleh sistem operasi. Setiap thread JVM dipetakan ke satu thread sistem operasi, dan setiap thread itu menyediakan stack sendiri untuk variabel lokal serta jejak pemanggilan fungsinya, lazimnya berukuran sekitar satu megabyte. Penjadwalan antar-thread dilakukan sistem operasi melalui pergantian konteks (context switch), yaitu proses menyimpan dan memuat kondisi eksekusi yang menimbulkan biaya di setiap pergantiannya.
Harga itu membuat jumlah thread tidak bisa ditambah tanpa batas. Ribuan thread berarti ribuan stack yang menempati memori, ditambah beban pergantian konteks yang semakin sering ketika jumlahnya melebihi jumlah inti prosesor. Program berbasis thread perlu mengelola ukuran pool-nya secara hati-hati, dan tetap saja skenario yang membutuhkan ribuan tugas menunggu secara bersamaan menjadi tidak praktis.
Thread juga menimbulkan biaya koordinasi antarunitnya. Dua thread yang mengubah data yang sama perlu saling sinkronisasi melalui mekanisme penguncian, dan kesalahan pada koordinasi itu memunculkan race condition, yaitu keadaan ketika hasil bergantung pada urutan eksekusi yang tidak terkendali. Bagian mental model ini tidak hilang dengan berpindah ke coroutine, tetapi coroutine menyederhanakan sisi menunggunya, karena thread yang menunggu dapat dibebaskan dan tidak menahan mekanisme penguncian lebih lama dari yang diperlukan.
Coroutine sebagai unit ringan#
Coroutine adalah unit pekerjaan yang dapat dijeda di tengah eksekusi tanpa memblokir thread yang menjalankannya. Saat coroutine menunggu sesuatu, misalnya data dari jaringan, thread yang tadinya memegangnya dibebaskan untuk menjalankan coroutine lain. Kondisi eksekusi coroutine disimpan dalam sebuah objek, bukan pada thread, sehingga biaya pembuatannya mendekati biaya membuat objek biasa dan bukan biaya membuat thread.
Pembagian kerjanya menjadi jelas setelah kedua model diletakkan berdampingan. Sistem operasi tetap menjadwalkan thread, sedangkan library coroutines menjadwalkan coroutine di atas thread-thread tersebut. Satu thread dapat menggantikan banyak coroutine secara bergantian, dan satu coroutine dapat pindah thread di tengah hidupnya tanpa ada bagian kode yang perlu menyadari perpindahan itu.
Pejeda coroutine juga bersifat eksplisit. Coroutine hanya berhenti di titik suspensi, yaitu pemanggilan fungsi suspend, dan di antara dua titik suspensi kodenya berjalan tanpa terputus. Sifat ini membuat alur program lebih mudah dipahami daripada preemptive threading, karena posisi pergantian terlihat pada tanda tangan fungsi dan tidak muncul kapan saja di tengah baris mana pun.
Sepuluh ribu coroutine dalam satu program#
Ringannya coroutine dapat dibuktikan dengan angka. Program berikut meluncurkan sepuluh ribu coroutine, masing-masing menunggu 100 milidetik lewat delay, lalu menghitung berapa yang selesai. Ekspresi List(10_000) { launch { ... } } memanggil lambda sekurang-kurangnya sepuluh ribu kali, dan setiap pemanggilannya meluncurkan satu coroutine sekaligus menyimpan Job-nya ke dalam daftar. Pada model thread, meluncurkan sepuluh ribu thread untuk pola yang sama berarti menyiapkan sepuluh ribu stack beserta beban penjadwalannya, sesuatu yang jarang dijalankan karena berat dan mudah membebani sistem.
import kotlinx.coroutines.*
fun main() = runBlocking {
var selesai = 0
val pekerjaan = List(10_000) {
launch {
delay(100)
selesai += 1
}
}
pekerjaan.forEach { it.join() }
println("$selesai coroutine selesai")
}# compiler Kotlin dan library coroutines yang dipakai jalur ini
export PATH=/home/kal/my-project/tools/kotlinc/bin:/home/kal/my-project/tools/jdk-merged/bin:$PATH
export LIBS=/home/kal/my-project/tools/libs
export STDLIB=/home/kal/my-project/tools/kotlinc/lib/kotlin-stdlib.jar
# kompilasi main.kt dengan library coroutines pada classpath, lalu jalankan programnya
kotlinc main.kt -cp "$LIBS/kotlinx-coroutines-core-jvm-1.11.0.jar" -d out.jar && java -cp "out.jar:$LIBS/kotlinx-coroutines-core-jvm-1.11.0.jar:$STDLIB" MainKt10000 coroutine selesaiSepuluh ribu coroutine selesai dalam satu jalankan program yang berjalan dalam hitungan detik. Variabel selesai bertambah aman karena seluruh coroutine pada program ini hidup di dalam runBlocking, yang menjalankan semuanya bergiliran pada satu thread yang sama. Pada dispatcher multithread, penambahan dari banyak thread sekaligus berisiko menimbulkan balapan pada penghitung, sehingga penghitungan seperti itu harus menggunakan struktur data yang aman untuk konkurensi. Perulangan forEach { it.join() } menunggu setiap coroutine selesai, dan baris terakhir baru dicetak setelah penghitung menunjukkan jumlah penuhnya.
Perbedaan yang menentukan#
Sifat kedua model dapat dirangkum dalam tabel berikut. Perbedaan yang paling menentukan terletak pada pemilik biayanya, yaitu siapa yang menanggung biaya menunggu dan pergantian konteks.
| Aspek | Thread | Coroutine |
|---|---|---|
| Penjadwal | Sistem operasi | Library coroutines di atas thread |
| Biaya pembuatan | Berat, dengan stack sendiri | Ringan, berupa objek |
| Jumlah realistis | Ratusan | Ratusan ribu |
| Saat menunggu | Thread diblokir dan tidak bisa dipakai | Thread dibebaskan untuk coroutine lain |
| Pembatalan | Tergantung mekanisme interrupt | cancel() berhenti pada titik suspensi |
Bacaan tabel tersebut mengarahkan desain program yang berisi banyak pekerjaan menunggu ke coroutine. Antrean permintaan jaringan, pembacaan berkas, dan penghitungan statistik yang selang-seling menunggu adalah contoh pekerjaan yang cocok, karena thread yang sama dapat melayani banyak coroutine yang sedang menunggu. Sebaliknya, pekerjaan berhitung berat tanpa waktu tunggu tetap berpulang ke jumlah thread, karena coroutine yang tidak pernah menjeda tetap memegang thread-nya sampai selesai. Peluang terbesar coroutine berada pada pekerjaan yang berjumlah banyak sekaligus banyak menunggu.
Kesalahan umum#
Kesalahan yang sering terjadi pada program coroutine adalah mengganti delay dengan Thread.sleep. Keduanya menunggu dengan lamanya waktu yang sama, tetapi akibatnya berbeda pada thread. Fungsi delay menjeda coroutine pada titik suspensi, sehingga thread yang menjalankannya bebas mengambil pekerjaan lain. Sebaliknya, Thread.sleep memblokir thread sepenuhnya, sehingga selama menunggu thread itu tidak bisa melakukan apa pun dan peluang konkurensi yang ditawarkan coroutine ikut hilang.
Pemblokiran semacam ini sulit dideteksi karena program tetap berjalan benar, hanya menjadi lebih lambat. Akibatnya baru terlihat ketika program memproses banyak pekerjaan sekaligus dan pool thread kehabisan karena semua threadnya sibuk tidur, misalnya pemrosesan arsip cadangan yang tiba-tiba memakan waktu berkali-kali lipat dari biasanya. Aturan yang perlu dipegang adalah menunggu lewat fungsi suspend seperti delay, sedangkan pemanggilan yang benar-benar memblokir dipindahkan ke dispatcher khusus agar tidak mengganggu coroutine lain.
Ringkasan#
- Thread adalah unit eksekusi milik sistem operasi dengan stack sendiri dan biaya pergantian konteks, sedangkan coroutine adalah pekerjaan yang dapat dijeda dan dijadwalkan library di atas thread.
- Saat coroutine menunggu di titik suspensi, thread yang menjalankannya dibebaskan, sehingga satu thread melayani banyak coroutine.
- Program pada topik ini meluncurkan sepuluh ribu coroutine dengan
delay100 milidetik dan seluruhnya selesai dalam satu jalankan program. delaymenjeda coroutine, sedangkanThread.sleepmemblokir thread, dan perbedaan inilah yang membuat coroutine tetap ringan.
Referensi#
- Coroutines guide — JetBrains — Panduan resmi coroutines Kotlin sebagai peta seluruh topik pada modul ini.
- Coroutines basics — JetBrains — Dokumentasi dasar coroutine, termasuk runBlocking, delay, dan penjelasan coroutine yang ringan.