Program yang berbicara dengan jaringan, basis data, atau disk menghabiskan sebagian besar waktunya untuk menunggu, bukan menghitung. Ketika aplikasi kita meminta laporan penjualan dari server lain, CPU tidak bekerja selama ratusan milidetik sampai jawaban tiba. Section Koncurrency telah menunjukkan bahwa thread OS membuat penantinan berjalan paralel, tetapi thread adalah unit yang mahal: setiap thread memesan stack sendiri dan dikelola langsung oleh kernel. Chapter ini mengukur batas thread tersebut dengan program nyata, lalu memperkenalkan model async — tugas-tugas ringan yang berbagi sedikit thread — sebagai fondasi seluruh Section Pemrograman Async.

Kegiatan yang Didominasi Tunggu #

Kita perlu membedakan dua jenis beban kerja sebelum memilih alat. Beban CPU-bound membuat CPU bekerja terus: menghitung faktorial besar, mengompresi berkas, atau mengenkripsi data — untuk beban seperti ini, thread dari Section Koncurrency adalah jawaban yang tepat. Beban I/O-bound justru didominasi penantinan: menunggu respons HTTP, menunggu hasil kueri basis data, atau menunggu data dari disk — selama penantinan itu CPU menganggur total. Server web, layanan API, dan bot pesan hampir seluruhnya I/O-bound karena pekerjaan inti mereka adalah menunggu pihak lain merespons.

thread::sleep adalah simulasi penantinan I/O yang jujur: thread benar-benar tidur di kernel selama durasi yang ditentukan, persis seperti thread yang menunggu paket jaringan tiba. Program berikut membandingkan dua model — satu thread menunggu permintaan bergiliran, versus satu thread OS per permintaan. Karena program ini hanya memakai pustaka standar, kita bisa menyimpannya sebagai main.rs lalu menjalankannya langsung dengan rustc main.rs && ./main tanpa satu dependensi pun:

rust
use std::thread;
use std::time::{Duration, Instant};

fn download_report(name: &str) -> String {
    // simulasi permintaan jaringan yang lambat
    thread::sleep(Duration::from_secs(1));
    format!("laporan {}", name)
}

fn main() {
    // model 1: satu thread menunggu permintaan bergiliran
    let start = Instant::now();
    let result1 = download_report("penjualan");
    let result2 = download_report("stok");
    let result3 = download_report("pengiriman");
    println!("sekuensial: {} detik", start.elapsed().as_secs());
    println!("{:?}", (result1, result2, result3));

    // model 2: satu thread OS per permintaan
    let start = Instant::now();
    let job1 = thread::spawn(|| download_report("penjualan"));
    let job2 = thread::spawn(|| download_report("stok"));
    let job3 = thread::spawn(|| download_report("pengiriman"));
    let results = (
        job1.join().unwrap(),
        job2.join().unwrap(),
        job3.join().unwrap(),
    );
    println!("thread: {} detik", start.elapsed().as_secs());
    println!("{:?}", results);
}

Output yang Dihasilkan:

text
sekuensial: 3 detik
("laporan penjualan", "laporan stok", "laporan pengiriman")
thread: 1 detik
("laporan penjualan", "laporan stok", "laporan pengiriman")

Mari kita bedah kode di atas:

  1. Baris 6 (thread::sleep(Duration::from_secs(1));): fungsi download_report mensimulasikan permintaan jaringan yang memakan waktu 1 detik. Selama 1 detik itu, thread yang memanggil fungsi ini tertidur di kernel dan CPU tidak mengerjakan apa pun.
  2. Baris 12 (let start = Instant::now();): Instant::now() merekam titik waktu sebagai awal pengukuran, persis seperti stopwatch yang dinyalakan sebelum lomba dimulai.
  3. Baris 13-15 (let result1 = ...; let result2 = ...; let result3 = ...;): tiga pemanggilan berjalan satu per satu. Setiap pemanggilan memblok thread selama 1 detik, sehingga totalnya 3 detik — kelemahan model sekuensial untuk pekerjaan I/O-bound.
  4. Baris 21-23 (let job1 = thread::spawn(...);): thread::spawn meluncurkan tiga thread OS terpisah. Setiap thread menjalankan satu download_report, sehingga tiga penantinan 1 detik berlangsung serentak, bukan bergiliran.
  5. Baris 25-27 (job1.join().unwrap(), ...): join() menunggu thread selesai dan mengambil nilai kembaliannya. Pola ini persis pola spawn-join dari Section Koncurrency.
  6. Baris 29 (println!("thread: {} detik", ...)): pengukuran kedua membaca 1 detik karena ketiga penantinan tumpang tindih — durasi total ditentukan penantinan terpanjang, bukan jumlah semua penantinan.

Thread memang menyelesaikan masalah paralelisme penantinan. Persoalannya muncul ketika jumlah penantinan bukan tiga, melainkan tiga puluh ribu.

Biaya Thread Saat Jumlahnya Membengkak #

Setiap thread OS membawa biaya tetap yang tidak bisa dihindari. Rust memesan stack sekitar 2 MiB untuk setiap thread std::thread secara bawaan; kernel juga mengalokasikan struktur data internal untuk mengelola thread tersebut. Kalikan dengan skala nyata: server yang melayani 10.000 koneksi simultan dengan satu thread per koneksi memesan hampir 20 GiB hanya untuk stack, sebelum satu byte data pun diproses. Pembuatan thread juga adalah syscall — operasi yang relatif mahal — sehingga membuat ribuan thread per detik menjadi beban tersendiri. Terakhir, penjadwalan thread bersifat preemptive: kernel dapat merebut thread kapan pun tanpa izin, dan semakin banyak thread yang menunggu dijadwalkan, semakin berat pekerjaan penjadwal kernel.

Model async menawarkan penukaran unit. Alih-alih satu thread per penantinan, kita memakai tugas (task): objek kecil berupa state machine yang hidup di heap dan menyerahkan kendali secara cooperative — task sendiri yang memutuskan kapan berhenti sejenak di titik .await. Ribuan task dikelola oleh executor di atas sedikit thread worker, biasanya sejumlah core CPU. Perbandingannya dapat dirangkum berikut:

AspekThread OSTask async
Memori dasarstack ±2 MiB per threadstate machine kecil di heap
Cara dibuatsyscall ke kernelalokasi memori biasa oleh executor
Penjadwalanpreemptive oleh kernelcooperative di titik .await
Skala praktisratusan hingga ribuanratusan ribu hingga jutaan

Model Async: Tugas Ringan dan Penyerahan Kendali #

Mekanisme inti async dapat diringkas dalam satu kalimat: saat sebuah task mencapai operasi yang belum selesai, task menyerahkan thread-nya kepada task lain yang siap bekerja, lalu kembali dipanggil ketika hasil yang ditunggu telah tersedia. Tidak ada thread yang berdiri diam menunggu; thread worker selalu menggarap task yang paling siap. Hubungan banyak task dengan sedikit thread dapat digambarkan sebagai berikut:

Diagram di atas memperlihatkan pembagian peran: keempat task hanya berupa data yang menunggu giliran, scheduler executor-lah yang memutuskan task mana yang naik ke thread worker, dan jumlah thread worker menempel pada jumlah core CPU. Batas analogi perlu dinyatakan: task bukan “thread kecil yang berjalan sendiri” — task hanya maju ketika executor memanggilnya, seperti naskah drama yang hanya hidup saat aktor memerankannya.

Perjalanan kita membangun model ini selangkah demi selangkah. Chapter berikutnya memperkenalkan async fn, kata kunci yang mendefinisikan fungsi penghasil tugas. Setelah itu kita mempelajari .await sebagai titik penyerahan kendali, runtime tokio sebagai executor yang menjalankan semuanya, tokio::spawn untuk meluncurkan banyak task, select! untuk merespons task tercepat, sampai penanganan galat di dunia asinkron.

Ringkasan #

Program I/O-bound didominasi penantinan, dan penantinan sekuensial menumpuk menjadi 3 detik untuk tiga permintaan berdurasi 1 detik. Thread OS menumpangkan penantinan menjadi 1 detik, tetapi setiap thread membawa stack ±2 MiB, syscall pembuatan, dan penjadwalan preemptive — skala 10.000 koneksi berarti hampir 20 GiB stack. Model async mengganti unit kerja menjadi task ringan berbentuk state machine yang dikelola scheduler executor di atas sedikit thread worker. Pertanyaan berikutnya sudah menanti: seperti apa bentuk kode yang menghasilkan task ringan itu? Jawabannya dimulai dari kata kunci async fn.