Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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.

Filename: src/main.rs
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));
    }
}
Listing 16-1: Membikin sebuah thread baru buat mencetak sesuatu sementara main thread mencetak hal lain

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.

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).

Filename: src/main.rs
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();
}
Listing 16-2: Menyimpan sebuah JoinHandle<T> dari thread::spawn buat menjamin thread-nya berjalan sampai selesai

Memanggil 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:

Filename: src/main.rs
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.

Filename: src/main.rs
use std::thread;

fn main() {
    let v = vec![1, 2, 3];

    let handle = thread::spawn(|| {
        println!("Here's a vector: {v:?}");
    });

    handle.join().unwrap();
}
Listing 16-3: Usaha buat memakai sebuah vector yang dibikin oleh main thread di dalam thread lainnya

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.

Filename: src/main.rs
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();
}
Listing 16-4: Sebuah thread dengan closure yang mencoba menangkap sebuah referensi ke v dari sebuah main thread yang men-drop v

Kalau 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.

Filename: src/main.rs
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();
}
Listing 16-5: Memakai keyword move buat memaksa sebuah closure untuk mengambil kepemilikan dari nilai-nilai yang dia pakai

Kita 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.