Threads

std::thread, spawn, dan join.

Daftar isi

Topik ini membahas thread pada Rust sebagai unit eksekusi yang berjalan serentak, mulai dari pembuatan thread baru dengan std::thread::spawn, pengumpulan hasil lewat join, sampai pemindahan kepemilikan data ke dalam thread dengan kata kunci move. Pembahasan mencakup nilai kembalian thread, bentuk kegagalan saat thread panic, serta perilaku urutan eksekusi yang tidak ditentukan oleh kode. Setelah membaca topik ini, pekerjaan yang dapat diparalelkan dapat dipecah ke beberapa thread dan seluruh hasilnya dikumpulkan kembali secara deterministik.

Thread sebagai unit eksekusi#

Thread adalah unit eksekusi yang dijadwalkan sistem operasi dan memiliki tumpukan panggilan sendiri. Setiap program Rust memulai pengeksekusiannya pada satu thread yang disebut thread utama, sedangkan std::thread menyediakan pembuatan thread tambahan yang dipetakan satu berbanding satu ke thread sistem operasi. Penjadwalan sepenuhnya diserahkan kepada sistem operasi, sehingga Rust tidak pernah menjanjikan kapan sebuah thread mendapat waktu prosesor. Fungsi spawn pada modul itu menjadi pintu masuk utamanya: fungsi tersebut menerima sebuah closure, menjalankannya pada thread baru, lalu segera mengembalikan kendali kepada pemanggil.

Program dengan beberapa thread disebut konkuren, sedangkan bagian yang benar-benar berjalan pada saat yang sama disebut paralel. Kedua sifat itu tidak ditentukan oleh kode, melainkan oleh penjadwal dan jumlah inti prosesor, sehingga program konkuren pada mesin satu inti tetap sah karena penjadwal mengganti-ganti thread secara cepat. Seluruh contoh pada topik ini tidak bergantung pada jumlah inti karena setiap hasil dikumpulkan melalui join sebelum dicetak.

Pembuatan thread juga membawa biaya: setiap thread mengalokasikan tumpukan sendiri dan perpindahan antar thread menimbulkan waktu penjadwalan. Untuk pekerjaan yang sangat kecil, biaya itu dapat melampaui keuntungan paralelisme, sehingga pemecahan pekerjaan perlu menimbang ukuran tiap bagian. Kebutuhan jumlah thread yang jauh melebihi jumlah inti lazimnya dilayani oleh pustaka thread pool atau oleh eksekutor async.

Membuat thread dengan spawn dan join#

Fungsi thread::spawn menjalankan closure pada thread baru dan mengembalikan JoinHandle<T>, yaitu pegangan yang tipe parameternya mengikuti nilai kembalian closure. Pegangan itu tidak menunggu thread dengan sendirinya: thread utama terus berjalan dan kedua thread bekerja serentak sampai join dipanggil. Metode join memblokir thread pemanggil hingga thread yang ditunggu selesai, kemudian mengembalikan Result yang membungkus nilai hasil closure. Tanpa join, thread utama dapat selesai lebih dahulu dan menghentikan seluruh proses sebelum pekerjaan tuntas.

Program berikut memecah penjumlahan dua kelompok nilai ke dua thread. Kedua closure mengembalikan hasil sebagai nilai kembalian, kata kunci move memindahkan kepemilikan Vec ke masing-masing thread, dan seluruh pencetakan dilakukan setelah kedua join selesai sehingga urutan keluarannya pasti.

rust pecah-kerja.rs
use std::thread;

fn main() {
    let bawah = vec![10, 24, 100];
    let atas = vec![7, 42, 19];

    let utas_bawah = thread::spawn(move || bawah.iter().sum::<i32>());
    let utas_atas = thread::spawn(move || atas.iter().sum::<i32>());

    let jumlah_bawah = utas_bawah.join().unwrap();
    let jumlah_atas = utas_atas.join().unwrap();

    println!("jumlah bawah : {}", jumlah_bawah);
    println!("jumlah atas  : {}", jumlah_atas);
    println!("total        : {}", jumlah_bawah + jumlah_atas);
}
text Keluaran
jumlah bawah : 134
jumlah atas  : 68
total        : 202

Nilai 134 dan 68 dihitung pada dua thread yang berbeda, dan penjumlahan akhir dilakukan thread utama setelah keduanya diterima. Susunan seperti ini tidak menyebut thread mana yang selesai lebih dahulu karena hal itu memang tidak ditentukan. Diagram berikut merangkum hubungan waktu ketiganya: spawn menandai awal masa hidup thread baru, sedangkan join menandai titik ketika thread utama menunggu berakhirnya masa hidup itu.

spawn membuat thread baru, join menunggu thread itu selesaithread utama (main)thread 1thread 2spawnspawnjoinjoinwaktu berjalan ke kanan, kotak menandai masa hidup tiap thread
Relasi waktu antara thread utama dan dua thread hasil spawn

Hasil thread dan muatan panic#

Nilai kembalian closure menjadi isi Ok dari join. Isi Err muncul apabila thread berhenti karena panic, dan panic semacam itu tidak menghentikan thread lain maupun keseluruhan proses. Muatan panic dikirim kepada pemanggil join dalam bentuk Box<dyn Any + Send>, sedangkan pesan panic bawaannya tetap tercetak pada keluaran error. Tipe dyn Any memungkinkan isi muatan diperiksa lewat downcast_ref tanpa mengetahui tipenya lebih dahulu.

Program berikut menjalankan satu thread yang panic lalu memeriksa muatan yang dikembalikan join. Nomor thread yang tercetak setelah nama thread pada pesan bervariasi pada setiap jalankan sehingga disaring dari keluaran.

bash
export PATH="$HOME/.cargo/bin:$PATH"
# thread yang panic hanya mengembalikan Err dari join, muatannya diperiksa
printf 'use std::thread;\n\nfn main() {\n    let pegangan = thread::spawn(|| {\n        panic!("sensor terputus");\n    });\n\n    match pegangan.join() {\n        Ok(nilai) => println!("thread selesai: {:?}", nilai),\n        Err(muatan) => match muatan.downcast_ref::<&str>() {\n            Some(pesan) => println!("thread gagal : {}", pesan),\n            None => println!("thread gagal : tanpa pesan"),\n        },\n    }\n}\n' > utas-panik.rs
# kompilasi lalu jalankan, nomor thread pada pesan bervariasi sehingga disaring
rustc --edition=2021 utas-panik.rs -o utas-panik && ./utas-panik 2>&1 | sed -E 's/ \([0-9]+\)//' | sed -n '1,8p'
text Keluaran

thread '<unnamed>' panicked at utas-panik.rs:5:9:
sensor terputus
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
thread gagal : sensor terputus

Baris thread '<unnamed>' panicked berasal dari mekanisme panic bawaan, dan program tetap berlanjut setelah baris itu karena panic hanya membubarkan thread yang mengalaminya. Panggilan downcast_ref::<&str>() mengembalikan Some karena panic!("sensor terputus") diberi teks literal sehingga muatannya bertipe &str. Tanpa join, pegangan yang dijatuhkan memisahkan thread dari pemanggilnya sehingga kegagalan itu tidak lagi dapat ditangani oleh kode. Metode join dengan demikian menjadi satu-satunya jalur bagi pemanggil untuk mengetahui kegagalan thread hasil spawn.

Memindahkan kepemilikan dengan move#

Closure yang dikirim ke spawn wajib memenuhi dua syarat: seluruh tangkapannya harus dapat dikirim ke thread lain, dan closure harus dapat hidup selama 'static karena Rust tidak mengetahui berapa lama thread itu berjalan. Konsekuensi syarat kedua berat bagi pemakaian biasa, yaitu closure tidak boleh meminjam data dari lingkup pemanggilnya. Tanpa kata kunci move, closure menangkap variabel luar lewat pinjaman, dan pinjaman itulah yang ditolak. Kompilasi program berikut menolak closure yang meminjam laporan tanpa move.

bash
export PATH="$HOME/.cargo/bin:$PATH"
# closure tanpa move meminjam data lokal dan ditolak kompilator
printf 'use std::thread;\n\nfn main() {\n    let laporan = String::from("catatan sensor");\n\n    let utas = thread::spawn(|| println!("{}", laporan));\n    utas.join().unwrap();\n}\n' > lupa-move.rs
# tampilkan bagian utama pesan error kompilasi
rustc --edition=2021 lupa-move.rs 2>&1 | sed -n '1,16p'
text Keluaran
error[E0373]: closure may outlive the current function, but it borrows `laporan`, which is owned by the current function
 --> lupa-move.rs:6:30
  |
6 |     let utas = thread::spawn(|| println!("{}", laporan));
  |                              ^^                ------- `laporan` is borrowed here
  |                              |
  |                              may outlive borrowed value `laporan`
  |
note: function requires argument type to outlive `'static`
 --> lupa-move.rs:6:16
  |
6 |     let utas = thread::spawn(|| println!("{}", laporan));
  |                ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
help: to force the closure to take ownership of `laporan` (and any other referenced variables), use the `move` keyword
  |
6 |     let utas = thread::spawn(move || println!("{}", laporan));

Error E0373 menyatakan bahwa closure mungkin hidup lebih lama daripada fungsi yang membuatnya sementara closure masih meminjam laporan, dan pesan itu menawarkan perbaikan yang tepat berupa kata kunci move. Dengan move, closure mengambil kepemilikan variabel alih-alih meminjamnya, sehingga syarat 'static terpenuhi karena data dimiliki thread selama masa hidup thread itu. Bentuk lengkapnya tampak pada program berikut: data berpindah masuk lewat move dan hasil pengolahannya kembali lewat nilai kembalian join.

rust pindah-teks.rs
use std::thread;

fn main() {
    let laporan = String::from("catatan sensor harian");

    let utas = thread::spawn(move || laporan.to_uppercase());

    let hasil = utas.join().unwrap();
    println!("hasil thread : {}", hasil);
    println!("panjang      : {}", hasil.len());
}
text Keluaran
hasil thread : CATATAN SENSOR HARIAN
panjang      : 21

Setelah laporan berpindah ke dalam closure, variabel asalnya tidak dapat dipakai lagi di thread utama karena kepemilikannya sudah tidak ada di sana. Hasil to_uppercase berupa String baru yang dikembalikan kepada thread utama lewat join, sehingga data mengalir masuk sekali dan keluar sekali tanpa pernah dimiliki dua thread pada saat yang sama. Pola kirim dengan move dan terima dengan join itulah bentuk pertukaran data antar thread yang paling sederhana.

Urutan eksekusi tidak ditentukan#

Urutan langkah antar thread ditentukan penjadwal sistem operasi, bukan oleh urutan penulisan kode. Bila beberapa thread mencetak pada saat bersamaan, urutan baris keluarannya berubah pada setiap jalankan, dan pada mesin banyak inti dua pencetakan dapat benar-benar berlangsung serentak. Menambahkan jeda seperti sleep tidak memulihkan determinisme, karena jadwal tetap menentukan urutan dan jeda hanya memperkecil peluang bertabrakan. Program yang keluarannya bergantung pada urutan seperti itu tidak dapat diuji dengan andal karena hasil pengujiannya sendiri ikut berubah.

Determinisme dipulihkan dengan memisahkan pekerjaan dari pelaporan: thread pekerja menghitung tanpa mencetak, hasil dikumpulkan lewat join atau lewat saluran pesan, dan pencetakan dilakukan dari satu thread setelah semua hasil siap. Seluruh contoh pada topik ini mengikuti pola tersebut, dan saluran pesan yang menjadi pokok bahasan topik Channels menyediakan bentuk pengumpulan hasil yang lebih umum. Jumlah bagian sendiri dapat disesuaikan dengan mesin pemakai lewat std::thread::available_parallelism, yang melaporkan perkiraan jumlah thread yang benar-benar berjalan paralel, sehingga pemecahan pekerjaan tidak perlu menebak jumlah inti.

Kesalahan umum#

Kesalahan pertama adalah membiarkan program berakhir tanpa join. Ketika thread utama selesai, proses dihentikan dan seluruh thread yang masih berjalan dibubarkan tanpa menyelesaikan pekerjaannya. Melewatkan JoinHandle begitu saja memang melepaskan thread dari pemanggilnya, tetapi pelepasan tersebut tidak menunggu thread selesai. Simpan pegangan dari setiap spawn, lalu panggil join sebelum keluar dari main atau sebelum hasilnya dibutuhkan.

Kesalahan kedua adalah mengharapkan urutan tertentu antar thread, misalnya menganggap thread yang dibuat lebih dahulu juga mencetak lebih dahulu. Gejalanya khas: keluaran program berubah antar jalankan padahal kodenya tidak berubah. Perbaikannya adalah mengumpulkan hasil seluruh thread, lalu mencetak sekumpulan hasil itu dari satu tempat setelah seluruh pekerjaan selesai.

Kesalahan ketiga adalah menganggap panic pada satu thread menggugurkan keseluruhan proses. Panic hanya membubarkan thread yang mengalaminya, sehingga thread lain tetap berjalan tanpa mengetahui kegagalan itu. Tangani Err dari join pada titik yang memang perlu bereaksi, misalnya dengan menghentikan sisa pekerjaan atau mencatat kejadiannya.

Ringkasan#

  • thread::spawn menjalankan closure pada thread baru dan mengembalikan JoinHandle<T>, sedangkan join memblokir pemanggil hingga thread selesai lalu menyerahkan Result berisi nilai hasil atau muatan panic.
  • Closure untuk spawn wajib mengambil kepemilikan data dengan move karena closure harus dapat hidup selama 'static, dan nilai hasilnya kembali kepada pemanggil lewat join.
  • Panic pada satu thread tidak menghentikan proses: muatannya dikirim kepada join sebagai Box<dyn Any + Send> dan dapat diperiksa dengan downcast_ref.
  • Urutan eksekusi antar thread tidak ditentukan, sehingga hasil dikumpulkan lebih dahulu lalu dicetak dari satu thread agar keluaran tetap deterministik.

Referensi#