join! di Chapter .await memaksa kita menunggu semua future sampai tuntas — padahal dunia nyata sering menuntut kebalikannya: ambil sumber mana pun yang paling cepat, dan abaikan sisanya. Aplikasi harga bertanya ke cache dan ke server pusat sekaligus lalu memakai jawaban pertama yang tiba; layanan menunggu operasi database namun mundur begitu tenggat waktu terlampaui; event loop harus siap merespons pesan mana pun yang datang lebih dulu. Makro tokio::select! dibuat persis untuk pola perlombaan ini. Chapter ini menjalankan dua balapan future nyata, membedah pola penulisan cabangnya, lalu membahas konsekuensi yang paling sering mengejutkan: cabang yang kalah dibatalkan begitu saja.

Merespons Cabang yang Selesai Lebih Dulu #

Bentuk paling sederhana dari select! adalah beberapa cabang future; ekspresi keseluruhan selesai ketika satu — cukup satu — cabang selesai, dan nilai kembaliannya diambil dari ekspresi cabang pemenang. Mari kita adu dua penantinan dengan durasi yang jauh berbeda:

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

#[tokio::main]
async fn main() {
    let start = Instant::now();

    let fast_source = tokio::time::sleep(Duration::from_secs(1));
    let slow_source = tokio::time::sleep(Duration::from_secs(3));

    let winner = tokio::select! {
        _ = fast_source => "server cache",
        _ = slow_source => "server pusat",
    };

    println!("sumber yang selesai lebih dulu: {}", winner);
    println!("total: {} detik", start.elapsed().as_secs());
}

Output yang Dihasilkan:

text
sumber yang selesai lebih dulu: server cache
total: 1 detik

Mari kita bedah kode di atas:

  1. Baris 7-8 (let fast_source = ...; let slow_source = ...;): kedua future timer dibangun di luar select! sebagai variabel biasa. Membangun future tetap gratis — belum ada penantinan yang dimulai.
  2. Baris 10-13 (let winner = tokio::select! { ... }): makro ini meng-poll kedua cabang bergantian pada task yang sama, seperti join!. Bedanya, select! berhenti di jawaban Ready pertama: timer 1 detik menyala lebih dulu, cabang pertama menang, dan ekspresi cabang "server cache" menjadi nilai winner.
  3. Baris 11 (_ = fast_source => "server cache"): pola setiap cabang adalah pola = future => ekspresi. Underscore di kiri berarti hasil future (yang bertipe unit untuk sleep) tidak dipakai; tanda => memisahkan sumber dari nilai yang dihasilkan cabang tersebut.
  4. Baris 16 (println!("total: {} detik", ...)): total waktu hanya 1 detik — bukan 3 detik. Angka ini adalah bukti penting: select! tidak menunggu cabang yang kalah. Setelah pemenang ditentukan, future slow_source dijatuhkan (dibatalkan) bersama sisa waktunya, sehingga program segera melanjutkan hidupnya.

Pembatalan cabang kalah inilah yang membuat select! bukan sekadar join! yang kurang sabar. Ketika timer 3 detik dibatalkan pada detik pertama, tidak ada thread yang menunggu sisa 2 detik — future-nya cukup dibuang dari meja.

Pola ini punya nama di industri: hedged request — layanan menembak dua sumber sekaligus lalu memakai yang tercepat, menukar beban jaringan tambahan dengan latensi yang lebih pendek. Pada contoh kita kedua sumbernya hanya timer; pada sistem nyata keduanya bisa jadi koneksi cache dan koneksi basis data yang sama-sama sedang menunggu jawaban.

Mengambil Nilai dari Cabang Pemenang #

Cabang tidak harus membuang hasilnya. Dengan mengikat hasil future ke nama, nilai dari sumber tercepat mengalir keluar dari select! — pola umum untuk meminta data ke beberapa sumber sekaligus dan memakai yang pertama tiba:

rust
use std::time::Duration;

async fn fetch_price(source: &str, seconds: u64) -> String {
    // simulasi server dengan kecepatan berbeda
    tokio::time::sleep(Duration::from_secs(seconds)).await;
    format!("harga dari {}", source)
}

#[tokio::main]
async fn main() {
    let data = tokio::select! {
        result = fetch_price("cache", 1) => result,
        result = fetch_price("kantor pusat", 3) => result,
    };
    println!("terpakai: {}", data);
}

Output yang Dihasilkan:

text
terpakai: harga dari cache

Mari kita bedah kode di atas:

  1. Baris 12 (result = fetch_price("cache", 1) => result): nama result mengikat String yang dihasilkan future ketika cabang ini menang. Kedua cabang sengaja memakai nama result yang sama dan bertipe sama (String), karena seluruh cabang select! harus menghasilkan satu tipe nilai kembali yang seragam — aturan yang sama berlaku pada cabang-cabang match dari Section Pattern Matching.
  2. Baris 13 (result = fetch_price("kantor pusat", 3) => result): sumber kedua diminta serentak dengan sumber pertama di task yang sama; pemanggilan fetch_price di sini dibangun oleh makro, bukan dieksekusi sebelum select! berjalan.
  3. Output terpakai: harga dari cache: jawaban cache (1 detik) tiba lebih dulu sehingga nilainya dipakai; permintaan ke kantor pusat dibatalkan di tengah jalan — 3 detik penuh tidak pernah ditunggu, dan respons kantor pusat (seandainya nyata) tidak akan pernah dibaca.

Sebelum lanjut, mari kita luruskan satu perbandingan yang sering membingungkan. tokio::time::timeout dari Chapter I/O Async dan pola select! dengan cabang timer sama-sama membatasi waktu tunggu, tetapi keduanya lahir dari kebutuhan yang berbeda: timeout membungkus satu future dengan satu tenggat, sementara select! membiarkan sejumlah future berlomba dalam satu arena. Ketika arena hanya berisi satu future dan satu timer, keduanya memang bisa saling menggantikan; begitu sumbernya lebih dari satu — misalnya cache, server pusat, dan saluran perintah pembatalan — select! menjadi satu-satunya bentuk yang tetap ringkas.

Perilaku di Balik Layar #

Tiga aturan perilaku perlu dihafal agar select! tidak mengejutkan kita di kemudian hari. Pertama, urutan polling cabang diacak: ketika select! pertama kali bertanya ke cabang-cabangnya, tokio mengacak urutannya demi keadilan, sehingga bila dua cabang sama-sama siap pada saat yang persis sama, pemenangnya tidak selalu cabang yang ditulis paling atas. Kedua, cabang yang kalah dijatuhkan: seluruh sisa pekerjaan future kalah — termasuk efek samping yang belum sempat terjadi — tidak dilanjutkan. Sebuah future yang akan menulis berkas atau mengirim pesan bisa berhenti setengah jalan jika kalah balapan; jika efek sampingnya penting, future tersebut harus ditempatkan di task terpisah lewat tokio::spawn, bukan sebagai cabang select!. Ketiga, future dibangun ulang setiap kali ekspresi select! dievaluasi — pola select! di dalam loop akan memulai penantinan segar di setiap putaran, yang justru menjadi dasar pola event loop; implementasi lanjutan pola ini membutuhkan pin! untuk menahan future di tempat, materi yang berada di luar cakupan section ini.

Ringkasan #

select! mengubah penantinan menjadi perlombaan: dua timer berdurasi 1 dan 3 detik selesai dalam 1 detik total karena cabang kalah dibatalkan, dan nilai cabang pemenang — harga dari cache — terikat keluar lewat pola result = future => result. Aturan mainnya tiga: urutan polling diacak demi keadilan, cabang kalah dijatuhkan bersama efek sampingnya, dan future cabang dibangun ulang setiap evaluasi. Karena itu efek samping penting ditempatkan di task spawn, bukan di cabang yang bisa kalah. Satu wilayah async masih tersisa: apa yang terjadi ketika future yang berlomba atau task yang berjalan justru gagal — dunia galat di async, pembukaan chapter penutup section ini.