Memakai Threads buat Menjalankan Kode Secara Bersamaan
Di sebagian besar sistem operasi saat ini, kode dari sebuah program yang dieksekusi dijalankan di dalam sebuah process (proses), dan sistem operasi bakal mengelola banyak proses sekaligus. Di dalam sebuah program, kita juga bisa punya bagian-bagian independen yang berjalan secara bersamaan (simultaneously). Fitur yang menjalankan bagian-bagian independen ini disebut threads (utas). Misalnya, sebuah web server bisa punya banyak threads sehingga ia bisa merespons lebih dari satu request (permintaan) di saat yang bersamaan.
Memecah komputasi di program kita menjadi banyak threads buat menjalankan banyak tugas di saat yang bersamaan bisa meningkatkan performa, tapi ini juga menambahkan kerumitan (complexity). Karena threads bisa berjalan secara bersamaan, tidak ada jaminan bawaan (inherent guarantee) tentang urutan bagian kode mana di threads yang berbeda yang bakal jalan duluan. Ini bisa berujung pada masalah-masalah, seperti:
- Race conditions (balapan kondisi), di mana threads mengakses data atau sumber daya (resources) dalam urutan yang tidak konsisten
- Deadlocks (jalan buntu), di mana dua threads saling menunggu satu sama lain, mencegah kedua threads tersebut buat bisa lanjut
- Bugs yang cuma terjadi di situasi-situasi tertentu dan susah buat direka ulang (reproduce) dan diperbaiki secara andal
Rust berusaha memitigasi efek-efek negatif dari memakai threads, tapi memprogram di dalam konteks multithreaded tetap butuh pemikiran yang hati-hati dan membutuhkan struktur kode yang berbeda dari program yang berjalan di satu thread saja (single thread).
Bahasa pemrograman mengimplementasikan threads dengan beberapa cara yang berbeda-beda, dan banyak sistem operasi menyediakan sebuah API yang bisa dipanggil oleh bahasa pemrograman tersebut buat membikin threads baru. Standard library Rust memakai model implementasi thread 1:1, di mana sebuah program memakai satu thread sistem operasi untuk satu thread bahasa. Ada crates yang mengimplementasikan model threading lain yang membikin trade-offs (pertukaran) yang beda dari model 1:1. (Sistem async Rust, yang bakal kita lihat di bab selanjutnya, menyediakan pendekatan lain buat konkurensi.)
Membikin Thread Baru dengan spawn
Buat membikin thread baru, kita memanggil fungsi thread::spawn lalu
memberikan sebuah closure (kita sudah membahas closures di Bab 13) yang
mengandung kode yang mau kita jalankan di thread baru tersebut. Contoh
di Listing 16-1 mencetak sedikit teks dari main thread (thread utama) dan
teks lainnya dari thread yang baru dibikin.
use std::thread;
use std::time::Duration;
fn main() {
thread::spawn(|| {
for i in 1..10 {
println!("hi number {i} from the spawned thread!");
thread::sleep(Duration::from_millis(1));
}
});
for i in 1..5 {
println!("hi number {i} from the main thread!");
thread::sleep(Duration::from_millis(1));
}
}
Perhatikan bahwa saat main thread dari program Rust selesai, semua threads yang baru dibikin (spawned threads) bakal dimatikan, tidak peduli apakah mereka sudah selesai berjalan atau belum. Output dari program ini mungkin bakal sedikit berbeda setiap kalinya, tapi bakal kelihatan mirip seperti berikut:
hi number 1 from the main thread!
hi number 1 from the spawned thread!
hi number 2 from the main thread!
hi number 2 from the spawned thread!
hi number 3 from the main thread!
hi number 3 from the spawned thread!
hi number 4 from the main thread!
hi number 4 from the spawned thread!
hi number 5 from the spawned thread!
Pemanggilan ke thread::sleep memaksa sebuah thread buat menghentikan
eksekusinya untuk durasi yang singkat, memungkinkan thread lain buat berjalan.
Threads tersebut kemungkinan bakal berjalan bergantian, tapi itu tidak dijamin:
itu bergantung sama gimana sistem operasi kita menjadwalkan (schedules)
threads tersebut. Di jalannya (run) kali ini, main thread mencetak duluan,
meskipun statement print dari spawned thread muncul duluan di kodenya. Dan
walaupun kita menyuruh spawned thread buat mencetak sampai i itu 9, ia cuma
sampai ke 5 sebelum main thread dimatikan.
Kalau kita menjalankan kode ini dan cuma melihat output dari main thread, atau tidak melihat tumpang tindih (overlap) apa pun, coba naikkan angka di rentangnya (ranges) buat membikin lebih banyak kesempatan buat sistem operasi beralih di antara threads tersebut.
Menunggu Semua Threads buat Selesai Memakai join Handles
Kode di Listing 16-1 tidak cuma menghentikan spawned thread sebelum waktunya (prematurely) di sebagian besar waktu karena main thread yang berakhir duluan, tapi karena tidak ada jaminan di urutan mana threads itu berjalan, kita juga tidak bisa menjamin apakah spawned thread itu bakal dapat kesempatan buat berjalan sama sekali!
Kita bisa membereskan masalah spawned thread yang tidak berjalan atau
berakhir sebelum waktunya dengan menyimpan nilai kembalian (return value) dari
thread::spawn ke dalam sebuah variabel. Tipe kembalian dari thread::spawn
adalah JoinHandle<T>. Sebuah JoinHandle<T> adalah nilai yang dimiliki (owned
value) yang, saat kita memanggil method join padanya, bakal menunggu sampai
thread-nya selesai. Listing 16-2 menunjukkan gimana cara memakai
JoinHandle<T> dari thread yang kita bikin di Listing 16-1 dan gimana cara
memanggil join buat memastikan spawned thread tersebut selesai sebelum main
keluar (exits).
use std::thread;
use std::time::Duration;
fn main() {
let handle = thread::spawn(|| {
for i in 1..10 {
println!("hi number {i} from the spawned thread!");
thread::sleep(Duration::from_millis(1));
}
});
for i in 1..5 {
println!("hi number {i} from the main thread!");
thread::sleep(Duration::from_millis(1));
}
handle.join().unwrap();
}
JoinHandle<T> dari thread::spawn buat menjamin thread-nya berjalan sampai selesaiMemanggil join pada handle bakal memblokir (blocks) thread yang saat ini
lagi jalan sampai thread yang diwakili oleh handle tersebut berhenti (terminates).
Memblokir sebuah thread berarti thread tersebut dicegah buat melakukan
pekerjaan atau keluar. Karena kita menaruh pemanggilan join setelah for loop
milik main thread, menjalankan Listing 16-2 seharusnya menghasilkan output
yang mirip kayak gini:
hi number 1 from the main thread!
hi number 2 from the main thread!
hi number 1 from the spawned thread!
hi number 3 from the main thread!
hi number 2 from the spawned thread!
hi number 4 from the main thread!
hi number 3 from the spawned thread!
hi number 4 from the spawned thread!
hi number 5 from the spawned thread!
hi number 6 from the spawned thread!
hi number 7 from the spawned thread!
hi number 8 from the spawned thread!
hi number 9 from the spawned thread!
Dua threads tersebut lanjut berjalan bergantian, tapi main thread menunggu
karena adanya pemanggilan handle.join() dan tidak berakhir sampai spawned
thread-nya selesai.
Tapi mari kita lihat apa yang terjadi kalau kita malah memindahkan handle.join()
sebelum for loop di main, kayak gini:
use std::thread;
use std::time::Duration;
fn main() {
let handle = thread::spawn(|| {
for i in 1..10 {
println!("hi number {i} from the spawned thread!");
thread::sleep(Duration::from_millis(1));
}
});
handle.join().unwrap();
for i in 1..5 {
println!("hi number {i} from the main thread!");
thread::sleep(Duration::from_millis(1));
}
}
Main thread bakal menunggu spawned thread buat selesai baru kemudian dia menjalankan for loop-nya, jadi outputnya tidak bakal tumpang tindih lagi, seperti yang ditunjukkan di sini:
hi number 1 from the spawned thread!
hi number 2 from the spawned thread!
hi number 3 from the spawned thread!
hi number 4 from the spawned thread!
hi number 5 from the spawned thread!
hi number 6 from the spawned thread!
hi number 7 from the spawned thread!
hi number 8 from the spawned thread!
hi number 9 from the spawned thread!
hi number 1 from the main thread!
hi number 2 from the main thread!
hi number 3 from the main thread!
hi number 4 from the main thread!
Detail-detail kecil, kayak di mana join itu dipanggil, bisa memengaruhi apakah
threads kita berjalan secara bersamaan atau tidak.
Memakai Closures move bersama Threads
Kita bakal sering memakai keyword move bersama closures yang diteruskan
ke thread::spawn karena closure tersebut kemudian bakal mengambil
kepemilikan atas nilai-nilai yang dia pakai dari lingkungannya, sehingga
mentransfer kepemilikan dari nilai-nilai tersebut dari satu thread ke thread
lainnya. Di “Menangkap Referensi atau Memindahkan Kepemilikan” di
Bab 13, kita sudah membahas move di dalam konteks closures. Sekarang kita
bakal lebih konsentrasi pada interaksi antara move dan thread::spawn.
Perhatikan di Listing 16-1 bahwa closure yang kita teruskan ke
thread::spawn tidak menerima argumen apa pun: kita tidak memakai data apa
pun dari main thread di dalam kode milik spawned thread. Buat memakai
data dari main thread di dalam spawned thread, closure si spawned thread
harus menangkap (capture) nilai-nilai yang dia butuhkan. Listing 16-3
menunjukkan usaha buat membikin sebuah vector di main thread dan memakainya
di dalam spawned thread. Namun, ini belum bisa jalan, seperti yang bakal kita
lihat sebentar lagi.
use std::thread;
fn main() {
let v = vec![1, 2, 3];
let handle = thread::spawn(|| {
println!("Here's a vector: {v:?}");
});
handle.join().unwrap();
}
Closure ini memakai v, jadi dia bakal menangkap v dan menjadikannya bagian
dari lingkungan closure tersebut. Karena thread::spawn menjalankan closure
ini di sebuah thread baru, kita seharusnya bisa mengakses v di dalam thread
baru tersebut. Tapi pas kita men-compile contoh ini, kita dapat error berikut:
$ cargo run
Compiling threads v0.1.0 (file:///projects/threads)
error[E0373]: closure may outlive the current function, but it borrows `v`, which is owned by the current function
--> src/main.rs:6:32
|
6 | let handle = thread::spawn(|| {
| ^^ may outlive borrowed value `v`
7 | println!("Here's a vector: {v:?}");
| - `v` is borrowed here
|
note: function requires argument type to outlive `'static`
--> src/main.rs:6:18
|
6 | let handle = thread::spawn(|| {
| __________________^
7 | | println!("Here's a vector: {v:?}");
8 | | });
| |______^
help: to force the closure to take ownership of `v` (and any other referenced variables), use the `move` keyword
|
6 | let handle = thread::spawn(move || {
| ++++
For more information about this error, try `rustc --explain E0373`.
error: could not compile `threads` (bin "threads") due to 1 previous error
Rust menebak (infers) gimana cara menangkap v, dan karena println! cuma
butuh sebuah referensi ke v, closure tersebut mencoba meminjam (borrow) v.
Namun, ada sebuah masalah: Rust tidak bisa memberi tahu berapa lama spawned
thread tersebut bakal berjalan, jadi dia tidak tahu apakah referensi ke v
itu bakal selalu valid.
Listing 16-4 memberikan skenario yang punya kemungkinan lebih tinggi di mana
referensi ke v tidak bakal valid.
use std::thread;
fn main() {
let v = vec![1, 2, 3];
let handle = thread::spawn(|| {
println!("Here's a vector: {v:?}");
});
drop(v); // oh no!
handle.join().unwrap();
}
v dari sebuah main thread yang men-drop vKalau Rust mengizinkan kita menjalankan kode ini, ada kemungkinan kalau spawned
thread tersebut bakal langsung ditaruh di background (latar belakang) tanpa
sempat dijalankan sama sekali. Spawned thread itu punya referensi ke v di
dalamnya, tapi main thread langsung men-drop (membuang) v, memakai
fungsi drop yang sudah kita bahas di Bab 15. Lalu, saat spawned thread
mulai dieksekusi, v sudah tidak valid lagi, jadi referensi ke dia juga ikutan
tidak valid. Waduh!
Buat membenarkan error compiler di Listing 16-3, kita bisa memakai saran dari pesan error-nya:
help: to force the closure to take ownership of `v` (and any other referenced variables), use the `move` keyword
|
6 | let handle = thread::spawn(move || {
| ++++
Dengan menambahkan keyword move sebelum closure, kita memaksa closure
buat mengambil kepemilikan dari nilai-nilai yang dia pakai, ketimbang membiarkan
Rust menebak kalau dia seharusnya meminjam (borrow) nilai-nilai tersebut.
Modifikasi buat Listing 16-3 yang ditunjukkan di Listing 16-5 bakal bisa
di-compile dan berjalan sesuai keinginan kita.
use std::thread;
fn main() {
let v = vec![1, 2, 3];
let handle = thread::spawn(move || {
println!("Here's a vector: {v:?}");
});
handle.join().unwrap();
}
move buat memaksa sebuah closure untuk mengambil kepemilikan dari nilai-nilai yang dia pakaiKita mungkin tergiur buat mencoba hal yang sama buat membenarkan kode di Listing
16-4 di mana main thread memanggil drop, dengan memakai closure move.
Namun, perbaikan ini tidak bakal bisa karena apa yang coba dilakukan oleh
Listing 16-4 itu tidak diizinkan buat alasan yang berbeda. Kalau kita menambahkan
move ke closure tersebut, kita bakal memindahkan v ke dalam lingkungan
closure-nya, dan kita tidak bisa lagi memanggil drop padanya di main thread.
Kita malah bakal dapat error compiler ini:
$ cargo run
Compiling threads v0.1.0 (file:///projects/threads)
error[E0382]: use of moved value: `v`
--> src/main.rs:10:10
|
4 | let v = vec![1, 2, 3];
| - move occurs because `v` has type `Vec<i32>`, which does not implement the `Copy` trait
5 |
6 | let handle = thread::spawn(move || {
| ------- value moved into closure here
7 | println!("Here's a vector: {v:?}");
| - variable moved due to use in closure
...
10 | drop(v); // oh no!
| ^ value used here after move
|
help: consider cloning the value before moving it into the closure
|
6 ~ let value = v.clone();
7 ~ let handle = thread::spawn(move || {
8 ~ println!("Here's a vector: {value:?}");
|
For more information about this error, try `rustc --explain E0382`.
error: could not compile `threads` (bin "threads") due to 1 previous error
Aturan ownership (kepemilikan) Rust sudah menyelamatkan kita lagi! Kita dapat
error dari kode di Listing 16-3 karena Rust bertindak konservatif dan cuma
meminjam v buat thread tersebut, yang mana berarti main thread secara
teoritis bisa membikin referensi si spawned thread jadi tidak valid. Dengan
memberi tahu Rust buat memindahkan kepemilikan dari v ke spawned thread,
kita menjamin ke Rust kalau main thread tidak bakal memakai v lagi. Kalau
kita mengubah Listing 16-4 dengan cara yang sama, kita malah melanggar aturan
kepemilikan saat kita mencoba memakai v di main thread. Keyword move
menimpa (overrides) aturan default konservatif Rust yang melakukan peminjaman
(borrowing); ia tidak mengizinkan kita buat melanggar aturan kepemilikannya.
Sekarang karena kita sudah membahas apa itu threads dan method-method yang disediakan oleh API thread, mari kita lihat beberapa situasi di mana kita bisa memakai threads.