Chapter sebelumnya memperkenalkan model thread OS dan satu contoh thread::spawn yang bekerja. Kini kita bedah dua fungsi inti yang mengatur seluruh siklus hidup thread: spawn yang meluncurkan thread, dan join yang menunggu thread selesai sambil mengambil hasil kerjanya. Dua fungsi ini menjawab dua kebutuhan nyata dari program concurrent — pekerjaan harus benar-benar berjalan terpisah, tetapi hasil pekerjaan itu tetap harus kembali ke tangan pemanggil dengan aman. Tanpa join, thread utama tidak punya cara untuk mengetahui apakah worker sudah rampung, apalagi mengambil nilai yang dihitung worker.

spawn mengembalikan JoinHandle #

Mekanisme spawn sengaja dibuat tidak menyekat: begitu thread baru tercipta, fungsi langsung kembali dan thread utama melanjutkan baris berikutnya. Hasil pemanggilan bukanlah hasil kerja thread, melainkan sebuah JoinHandle<T> — semacam tiket yang bisa ditukar kelak. Huruf T adalah tipe nilai kembalian closure yang dijalankan thread; bila closure mengembalikan String, tiket yang kita pegang bertipe JoinHandle<String>.

Tiket tersebut ditukar lewat metode join(). Metode ini memblokir thread pemanggil sampai thread pekerja selesai, lalu mengantarkan nilai kembalian closure. Mari kita lihat pola lengkapnya:

rust
use std::thread;

fn main() {
    let pekerja = thread::spawn(|| {
        let daftar_harga = [15000, 8000, 17500];
        let total: i32 = daftar_harga.iter().sum();
        format!("Total belanja: Rp{}", total)
    });

    let laporan = pekerja.join().unwrap();
    println!("{}", laporan);
    println!("Thread utama melanjutkan pekerjaan lain");
}

Output yang Dihasilkan:

text
Total belanja: Rp40500
Thread utama melanjutkan pekerjaan lain

Mari kita bedah kode di atas:

  1. Baris 4 (let pekerja = thread::spawn(|| {): closure di Baris 4-7 berjalan di thread baru. Karena closure berakhir dengan ekspresi format!(...) bertipe String, variabel pekerja bertipe JoinHandle<String>.
  2. Baris 7 (format!("Total belanja: Rp{}", total)): nilai ini menjadi nilai kembalian thread — bukan nilai kembalian spawn. spawn sudah selesai jauh sebelum angka 40500 selesai dihitung.
  3. Baris 10 (let laporan = pekerja.join().unwrap();): main berhenti sementara di baris ini sampai thread pekerja rampung, lalu String hasil kerja berpindah ke variabel laporan. Perhatikan unwrap() — kita bahas alasannya di bagian berikutnya.
  4. Baris 11-12: dua baris cetak ini pasti tereksekusi setelah worker selesai karena join sudah menjadi pagar di Baris 10, sehingga urutan output selalu sama pada setiap eksekusi.

Perlu dicatat ulang: selama thread pekerja menghitung, thread utama bebas mengerjakan hal lain — potensi itu tidak hilang karena join. Penempatan join adalah keputusan desain: panggil lebih awal bila hasil segera dibutuhkan, panggil belakangan bila masih ada pekerjaan lain untuk dikerjakan sambil menunggu.

join mengembalikan Result #

Tipe kembalian join() bukan T melainkan Result<T, Box<dyn Any + Send>>. Kebutuhan Result lahir dari satu fakta penting: panic di dalam thread tidak menjatuhkan keseluruhan proses — panic menghentikan thread tempatnya terjadi saja, dan proses tetap hidup. Ketika worker panic, join() mengembalikan Err yang membungkus payload panic (pesan di dalam panic!), sehingga pemanggil bisa memutuskan langkah penanganan selanjutnya. Mari kita buktikan dengan worker yang sengaja panic:

rust
use std::thread;

fn main() {
    let pekerja = thread::spawn(|| {
        panic!("Data pesanan tidak ditemukan");
    });

    let hasil = pekerja.join();

    match hasil {
        Ok(_) => println!("Pekerja selesai tanpa masalah"),
        Err(_) => println!("Pekerja gagal: thread mengalami panic"),
    }

    println!("Program utama tetap berjalan normal");
}

Output yang Dihasilkan:

text
thread '<unnamed>' (24457) panicked at main.rs:5:9:
Data pesanan tidak ditemukan
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
Pekerja gagal: thread mengalami panic
Program utama tetap berjalan normal

Mari kita bedah kode di atas:

  1. Baris 4-6: worker hanya berisi satu panic!. Panic ini membunuh thread worker, bukan proses — dan karena worker tidak memegang kunci atau janji apa pun, tidak ada yang tercemar di thread lain.
  2. Baris 8 (let hasil = pekerja.join();): join() tetap selesai (tidak menggantung selamanya) meskipun threadnya panic; bedanya, nilai yang kembali berada di cabang Err.
  3. Baris 12 (Err(_) => println!(...)): pola Err(_) mengabaikan payload panic dan cukup mencatat kegagalannya. Payload sebenarnya bisa diambil lewat metode downcast_ref dari Box<dyn Any + Send> bila program butuh membaca pesan panic secara terprogram.
  4. Baris 15: baris ini tercetak normal — bukti terkuat bahwa panic satu thread tidak mematikan proses.

Dua catatan penting untuk output di atas. Pertama, tiga baris pertama (pesan panic beserta catatannya) ditulis ke stderr oleh panic handler bawaan Rust, sedangkan dua baris terakhir ditulis ke stdout; output di atas adalah hasil penggabungan keduanya saat program dijalankan di terminal, dan baris kosong yang biasa muncul sebelum thread ... adalah pemisah visual buatan panic handler — kita hapus dari tampilan agar ringkas. Kedua, angka 24457 di dalam tanda kurung adalah ID thread yang dicetak panic handler — angka ini berbeda pada setiap eksekusi, jadi pembaca akan melihat nilai lain di mesin masing-masing. Urutan barisnya sendiri selalu sama: panic pasti terjadi sebelum join() kembali, sehingga pesan panic selalu mendahului kedua baris println!.

Mengoordinasikan Banyak Thread #

Program nyata jarang punya satu worker saja. Pola yang lazim: pecah pekerjaan menjadi beberapa bagian, luncurkan satu thread per bagian, kumpulkan semua JoinHandle ke dalam Vec, dan tukarkan seluruh tiket di akhir:

rust
use std::thread;

fn main() {
    let daftar_tugas = vec![
        vec![12000, 8000, 15000],
        vec![9000, 17500],
        vec![8000, 12000, 9000],
    ];

    let mut handles = Vec::new();

    for tugas in daftar_tugas {
        handles.push(thread::spawn(move || {
            let subtotal: i32 = tugas.iter().sum();
            subtotal
        }));
    }

    let mut total_semua = 0;
    for handle in handles {
        total_semua += handle.join().unwrap();
    }

    println!("Total pendapatan semua kasir: Rp{}", total_semua);
}

Output yang Dihasilkan:

text
Total pendapatan semua kasir: Rp90500

Mari kita bedah kode di atas:

  1. Baris 4-8 (let daftar_tugas = vec![...]): pekerjaan dipecah menjadi tiga Vec<i32> — satu per kasir. Struktur data ini menentukan jumlah thread yang dibuat.
  2. Baris 13 (handles.push(thread::spawn(move || {): setiap iterasi meluncurkan satu thread. Kata kunci move memindahkan kepemilikan tugas (satu Vec<i32>) ke closure — topik yang kita perdalam di Chapter Closure move di Thread.
  3. Baris 14 (let subtotal: i32 = tugas.iter().sum();): setiap worker menjumlahkan bagian miliknya sendiri tanpa menyentuh data kasir lain, sehingga tidak ada dua thread yang berbagi variabel.
  4. Baris 20-22 (for handle in handles { ... }): pemanggilan join berurutan untuk semua handle. Ketiga worker sebenarnya sudah berjalan bersamaan sejak loop pertama; loop kedua hanya mengambil hasil dan menjumlahkannya menjadi 90500.

Ringkasan #

thread::spawn meluncurkan thread OS dan langsung menyerahkan JoinHandle<T> — tiket menuju hasil kerja closure. Metode join() menukar tiket tersebut dengan menunggu thread rampung dan mengantarkan Result: Ok membawa nilai kembalian closure, Err membawa payload panic yang tidak ikut menjatuhkan proses. Banyak worker dikoordinasikan dengan Vec<JoinHandle> yang ditukar satu per satu, dan main yang keluar tanpa join mematikan semua thread yang tersisa. Satu hal belum terjawab: bagaimana cara mengirim data milik main ke dalam thread? Jawabannya adalah closure move — Chapter berikutnya.