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

Konkurensi Shared-State (Keadaan Berbagi)

Pengiriman pesan (message passing) adalah cara yang bagus buat menangani konkurensi, tapi itu bukan satu-satunya cara. Metode lain adalah dengan membiarkan banyak threads buat mengakses data bersama (shared data) yang sama. Ingat kembali slogan dari dokumentasi bahasa Go ini: “Jangan berkomunikasi dengan membagikan (sharing) memori.”

Kira-kira gimana sih bentuknya berkomunikasi dengan membagikan memori itu? Selain itu, kenapa para penggemar message-passing mewanti-wanti buat tidak memakai berbagi memori (memory sharing)?

Di satu sisi, channels (saluran) di bahasa pemrograman mana pun itu mirip dengan kepemilikan tunggal (single ownership) karena begitu kita mentransfer sebuah nilai lewat channel, kita tidak seharusnya memakai nilai itu lagi. Konkurensi memori bersama (shared-memory concurrency) itu kayak kepemilikan ganda (multiple ownership): banyak threads bisa mengakses lokasi memori yang sama di saat yang bersamaan. Seperti yang udah kita lihat di Bab 15, di mana smart pointers memungkinkan kepemilikan ganda, kepemilikan ganda bisa nambahin kerumitan (complexity) karena pemilik-pemilik yang berbeda ini butuh dikelola (managed). Sistem tipe (type system) dan aturan ownership Rust ngebantu sekali buat bikin pengelolaan ini jadi benar. Sebagai contoh, mari kita lihat mutexes, salah satu dari struktur data konkurensi (concurrency primitives) yang paling umum dipakai buat memori bersama.

Memakai Mutexes buat Mengizinkan Akses ke Data dari Satu Thread dalam Satu Waktu

Mutex adalah singkatan dari mutual exclusion (pengecualian timbal balik), yang artinya sebuah mutex cuma mengizinkan satu thread aja buat mengakses beberapa data di satu waktu tertentu. Buat mengakses data di dalam sebuah mutex, sebuah thread pertama-tama harus ngasih sinyal kalau dia mau akses dengan meminta buat ngambil (acquire) lock (kunci) milik mutex tersebut. Lock adalah struktur data yang jadi bagian dari mutex yang melacak siapa yang saat ini punya akses eksklusif ke datanya. Oleh karena itu, mutex digambarkan sebagai penjaga (guarding) data yang dia pegang melalui sistem locking ini.

Mutexes terkenal susah dipakai karena kita harus ingat dua aturan ini:

  1. Kita harus mencoba mengambil (acquire) lock-nya sebelum memakai datanya.
  2. Pas kita kelar sama data yang dijaga sama mutex tersebut, kita harus membuka kunci (unlock) datanya supaya threads lain bisa mengambil lock itu.

Sebagai metafora dunia nyata buat sebuah mutex, bayangin sebuah panel diskusi di sebuah konferensi yang cuma punya satu mikrofon. Sebelum seorang panelis bisa ngomong, dia harus minta atau ngasih sinyal kalau dia mau memakai mikrofonnya. Saat dia dapat mikrofonnya, dia bisa ngomong selama yang dia mau lalu memberikan mikrofonnya ke panelis berikutnya yang meminta buat ngomong. Kalau ada panelis yang lupa ngasih mikrofonnya ke orang lain pas udah selesai, tidak bakal ada orang lain yang bisa ngomong. Kalau pengelolaan mikrofon bersama ini jadi berantakan, panelnya tidak bakal berjalan sesuai rencana!

Pengelolaan mutex bisa jadi susah sekali buat dibikin benar, itulah kenapa banyak orang jadi antusias sekali sama channels. Namun, berkat sistem tipe dan aturan ownership di Rust, kita tidak mungkin salah mengunci dan membuka kunci.

API dari Mutex<T>

Sebagai contoh gimana cara memakai mutex, mari mulai dengan memakai sebuah mutex di dalam konteks satu thread (single-threaded), seperti yang ditunjukkan di Listing 16-12.

Filename: src/main.rs
use std::sync::Mutex;

fn main() {
    let m = Mutex::new(5);

    {
        let mut num = m.lock().unwrap();
        *num = 6;
    }

    println!("m = {m:?}");
}
Listing 16-12: Mengeksplorasi API dari Mutex<T> di dalam konteks single-threaded buat kesederhanaan

Sama kayak banyak tipe lainnya, kita membikin sebuah Mutex<T> dengan memakai fungsi associated new. Buat mengakses data di dalam mutex-nya, kita memakai method lock buat ngambil lock-nya. Pemanggilan ini bakal memblokir (block) thread yang saat ini lagi jalan sehingga ia tidak bisa melakukan kerjaan apa-apa sampai giliran kita dapat lock-nya.

Pemanggilan ke lock bakal gagal kalau thread lain yang sedang memegang lock tersebut mengalami panic. Di kasus itu, tidak bakal ada yang bisa ngedapetin lock-nya lagi, jadi kita memilih buat unwrap dan membikin thread ini panic kalau kita ada di situasi tersebut.

Setelah kita dapat lock-nya, kita bisa memperlakukan nilai kembaliannya, yang kita namakan num di sini, sebagai referensi mutable ke data internalnya. Sistem tipe memastikan kalau kita dapat lock-nya dulu sebelum memakai nilai di dalam m. Tipe dari m adalah Mutex<i32>, bukannya i32, jadi kita harus memanggil lock supaya bisa memakai nilai i32 tersebut. Kita tidak bisa lupa; sistem tipenya tidak bakal ngebiarin kita mengakses i32 internal itu kalau kita tidak melakukannya.

Pemanggilan ke lock mengembalikan sebuah tipe bernama MutexGuard, yang dibungkus di dalam sebuah LockResult yang tadi kita tangani dengan panggilan ke unwrap. Tipe MutexGuard mengimplementasikan Deref agar dia menunjuk ke data internal kita; tipe ini juga punya implementasi Drop yang melepaskan lock-nya secara otomatis saat sebuah MutexGuard keluar dari scope, yang mana terjadi di akhir dari inner scope (scope dalam). Sebagai hasilnya, kita tidak bakal ambil risiko lupa melepaskan lock-nya dan ngeblokir mutex tersebut dari dipakai sama threads lain, karena pelepasan lock itu terjadi secara otomatis.

Setelah lock-nya di-drop, kita bisa mencetak nilai mutex tersebut dan melihat kalau kita berhasil ngubah i32 internalnya jadi 6.

Berbagi Mutex<T> di Antara Beberapa Threads

Sekarang mari kita coba membagikan sebuah nilai di antara beberapa threads memakai Mutex<T>. Kita bakal membikin (spin up) 10 threads dan menyuruh masing-masing dari mereka buat menaikkan nilai penghitung (counter) sebanyak 1, sehingga penghitungnya berjalan dari 0 sampai 10. Contoh di Listing 16-13 bakal mengalami error compiler, dan kita bakal memakai error itu buat belajar lebih banyak soal memakai Mutex<T> dan gimana Rust ngebantu kita memakainya dengan benar.

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

fn main() {
    let counter = Mutex::new(0);
    let mut handles = vec![];

    for _ in 0..10 {
        let handle = thread::spawn(move || {
            let mut num = counter.lock().unwrap();

            *num += 1;
        });
        handles.push(handle);
    }

    for handle in handles {
        handle.join().unwrap();
    }

    println!("Result: {}", *counter.lock().unwrap());
}
Listing 16-13: Sepuluh threads, di mana masing-masing menaikkan penghitung yang dijaga sama sebuah Mutex<T>

Kita bikin sebuah variabel counter buat memegang sebuah i32 di dalam sebuah Mutex<T>, kayak yang kita lakuin di Listing 16-12. Selanjutnya, kita bikin 10 threads dengan iterasi melewati serangkaian angka. Kita memakai thread::spawn dan ngasih closure yang sama ke semua threads itu: sebuah closure yang memindahkan (moves) penghitung tersebut ke dalam thread, mengambil lock pada Mutex<T> dengan memanggil method lock, lalu menambahkan 1 ke nilai yang ada di dalam mutex tersebut. Pas sebuah thread kelar ngejalanin closure-nya, num bakal keluar dari scope dan melepaskan lock-nya supaya thread lain bisa mengambil lock itu.

Di main thread, kita mengumpulkan (collect) semua join handles. Terus, sama kayak di Listing 16-2, kita memanggil join pada setiap handle buat memastikan semua threads selesai. Di titik itu, main thread bakal ngambil lock-nya dan mencetak hasil dari program ini.

Kita tadi udah ngebocorin kalau contoh ini tidak bakal bisa di-compile. Sekarang mari kita cari tahu alasannya kenapa!

$ cargo run
   Compiling shared-state v0.1.0 (file:///projects/shared-state)
error[E0382]: borrow of moved value: `counter`
  --> src/main.rs:21:29
   |
 5 |     let counter = Mutex::new(0);
   |         ------- move occurs because `counter` has type `std::sync::Mutex<i32>`, which does not implement the `Copy` trait
...
 8 |     for _ in 0..10 {
   |     -------------- inside of this loop
 9 |         let handle = thread::spawn(move || {
   |                                    ------- value moved into closure here, in previous iteration of loop
...
21 |     println!("Result: {}", *counter.lock().unwrap());
   |                             ^^^^^^^ value borrowed here after move
   |
help: consider moving the expression out of the loop so it is only moved once
   |
 8 ~     let mut value = counter.lock();
 9 ~     for _ in 0..10 {
10 |         let handle = thread::spawn(move || {
11 ~             let mut num = value.unwrap();
   |

For more information about this error, try `rustc --explain E0382`.
error: could not compile `shared-state` (bin "shared-state") due to 1 previous error

Pesan error-nya bilang kalau nilai counter itu udah dipindahkan (moved) di iterasi loop sebelumnya. Rust ngasih tahu kita kalau kita tidak bisa memindahkan kepemilikan lock counter ke dalam beberapa threads. Mari kita perbaiki error compiler ini dengan metode kepemilikan ganda (multiple- ownership method) yang udah kita bahas di Bab 15.

Multiple Ownership dengan Multiple Threads

Di Bab 15, kita ngasih sebuah nilai ke beberapa pemilik (multiple owners) dengan memakai smart pointer Rc<T> buat membikin nilai yang jumlah referensinya dilacak (reference counted value). Mari kita lakuin hal yang sama di sini dan lihat apa yang terjadi. Kita bakal ngebungkus Mutex<T> ke dalam Rc<T> di Listing 16-14 dan meng-clone Rc<T>-nya sebelum memindahkan kepemilikannya ke dalam thread.

Filename: src/main.rs
use std::rc::Rc;
use std::sync::Mutex;
use std::thread;

fn main() {
    let counter = Rc::new(Mutex::new(0));
    let mut handles = vec![];

    for _ in 0..10 {
        let counter = Rc::clone(&counter);
        let handle = thread::spawn(move || {
            let mut num = counter.lock().unwrap();

            *num += 1;
        });
        handles.push(handle);
    }

    for handle in handles {
        handle.join().unwrap();
    }

    println!("Result: {}", *counter.lock().unwrap());
}
Listing 16-14: Mencoba memakai Rc<T> buat mengizinkan beberapa threads buat memiliki Mutex<T>

Sekali lagi, kita compile dan… dapat error yang beda! Compiler-nya ngajarin kita banyak hal.

$ cargo run
   Compiling shared-state v0.1.0 (file:///projects/shared-state)
error[E0277]: `Rc<std::sync::Mutex<i32>>` cannot be sent between threads safely
  --> src/main.rs:11:36
   |
11 |           let handle = thread::spawn(move || {
   |                        ------------- ^------
   |                        |             |
   |  ______________________|_____________within this `{closure@src/main.rs:11:36: 11:43}`
   | |                      |
   | |                      required by a bound introduced by this call
12 | |             let mut num = counter.lock().unwrap();
13 | |
14 | |             *num += 1;
15 | |         });
   | |_________^ `Rc<std::sync::Mutex<i32>>` cannot be sent between threads safely
   |
   = help: within `{closure@src/main.rs:11:36: 11:43}`, the trait `Send` is not implemented for `Rc<std::sync::Mutex<i32>>`
note: required because it's used within this closure
  --> src/main.rs:11:36
   |
11 |         let handle = thread::spawn(move || {
   |                                    ^^^^^^^
note: required by a bound in `spawn`
  --> /rustc/1159e78c4747b02ef996e55082b704c09b970588/library/std/src/thread/mod.rs:723:1

For more information about this error, try `rustc --explain E0277`.
error: could not compile `shared-state` (bin "shared-state") due to 1 previous error

Wow, pesan error-nya panjang sekali ya! Ini bagian penting yang perlu jadi fokus: `Rc<Mutex<i32>>` cannot be sent between threads safely. Compiler-nya juga ngasih tahu kita apa alasannya: the trait `Send` is not implemented for `Rc<Mutex<i32>>`. Kita bakal ngomongin soal Send di bagian selanjutnya: dia adalah salah satu trait yang memastikan tipe-tipe yang kita pakai bersama threads emang ditujukan buat dipakai di situasi yang konkuren.

Sayangnya, Rc<T> tidak aman buat dibagikan antar threads. Saat Rc<T> mengelola reference count, dia nambahin jumlahnya buat setiap panggilan ke clone dan ngurangin jumlahnya saat setiap clone di-drop. Tapi dia tidak memakai concurrency primitives apa pun buat memastikan kalau perubahan pada jumlah itu tidak diinterupsi sama thread lain. Hal ini bisa menyebabkan perhitungan (counts) yang salah—bugs halus (subtle bugs) yang pada akhirnya bisa menyebabkan memory leaks (kebocoran memori) atau sebuah nilai di-drop sebelum kita selesai memakainya. Apa yang kita butuhkan adalah sebuah tipe yang persis kayak Rc<T>, tapi yang ngubah jumlah referensinya pakai cara yang thread-safe (aman di lingkungan utas ganda).

Atomic Reference Counting dengan Arc<T>

Untungnya, Arc<T> adalah tipe yang mirip Rc<T> yang aman buat dipakai di situasi yang konkuren. Huruf A-nya singkatan dari atomic, yang artinya dia adalah tipe atomically reference-counted (penghitungan referensi secara atomik). Atomics adalah jenis primitif konkurensi tambahan yang tidak bakal kita bahas secara mendetail di sini: cek dokumentasi standard library buat std::sync::atomic buat detail lebih lanjut. Di titik ini, kita cuma perlu tahu kalau atomics bekerja kayak tipe primitif biasa tapi aman buat dibagikan antar threads.

Terus kita mungkin penasaran kenapa tidak semua tipe primitif dibikin jadi atomic dan kenapa tipe-tipe standard library tidak diimplementasikan buat memakai Arc<T> secara default. Alasannya adalah keamanan thread (thread safety) itu datang dengan penalti performa (performance penalty) yang mana kita cuma mau membayarnya saat kita bener-bener butuh. Kalau kita cuma ngelakuin operasi pada nilai di dalam satu thread, kode kita bisa berjalan lebih kencang kalau dia tidak perlu memaksakan jaminan yang disediakan sama atomics.

Mari kembali ke contoh kita: Arc<T> dan Rc<T> punya API yang sama, jadi kita memperbaiki program kita dengan mengubah baris use, panggilan ke new, dan panggilan ke clone. Kode di Listing 16-15 akhirnya bakal bisa di-compile dan jalan.

Filename: src/main.rs
use std::sync::{Arc, Mutex};
use std::thread;

fn main() {
    let counter = Arc::new(Mutex::new(0));
    let mut handles = vec![];

    for _ in 0..10 {
        let counter = Arc::clone(&counter);
        let handle = thread::spawn(move || {
            let mut num = counter.lock().unwrap();

            *num += 1;
        });
        handles.push(handle);
    }

    for handle in handles {
        handle.join().unwrap();
    }

    println!("Result: {}", *counter.lock().unwrap());
}
Listing 16-15: Memakai sebuah Arc<T> buat ngebungkus Mutex<T> supaya bisa membagikan kepemilikan di beberapa threads

Kode ini bakal mencetak output berikut:

Result: 10

Kita berhasil! Kita menghitung dari 0 sampai 10, yang mungkin tidak kelihatan terlalu mengesankan, tapi itu banyak ngajarin kita soal Mutex<T> dan keamanan thread. Kita juga bisa memakai struktur program ini buat melakukan operasi yang lebih rumit ketimbang cuma menaikkan nilai penghitung. Memakai strategi ini, kita bisa membagi perhitungan jadi bagian-bagian independen, memecah bagian- bagian itu ke berbagai threads, lalu memakai Mutex<T> agar masing-masing thread bisa meng-update hasil akhirnya dengan bagian perhitungan mereka.

Perhatikan bahwa kalau kita ngelakuin operasi angka (numerical operations) yang simpel, ada tipe yang lebih sederhana ketimbang tipe Mutex<T> yang disediakan sama modul std::sync::atomic di standard library. Tipe-tipe ini menyediakan akses yang aman, konkuren, dan atomik ke tipe-tipe primitif. Kita memilih buat memakai Mutex<T> bersama sebuah tipe primitif buat contoh ini supaya kita bisa berkonsentrasi pada gimana Mutex<T> itu bekerja.

Kesamaan Antara RefCell<T>/Rc<T> dan Mutex<T>/Arc<T>

kita mungkin nyadar kalau counter itu immutable tapi kita bisa dapat sebuah referensi mutable ke nilai di dalamnya; ini artinya Mutex<T> menyediakan interior mutability (mutabilitas interior), sama kayak keluarga Cell. Dengan cara yang sama seperti kita memakai RefCell<T> di Bab 15 buat memungkinkan kita memutasi isi di dalam sebuah Rc<T>, kita memakai Mutex<T> buat memutasi isi di dalam sebuah Arc<T>.

Detail lain yang perlu diperhatikan adalah Rust tidak bisa melindungi kita dari semua jenis logic errors (kutu logika) pas kita memakai Mutex<T>. Ingat kembali dari Bab 15 kalau memakai Rc<T> punya risiko membikin reference cycles (siklus referensi), di mana dua nilai Rc<T> merujuk ke satu sama lain, yang menyebabkan memory leaks. Serupa dengan hal itu, Mutex<T> punya risiko membikin deadlocks (jalan buntu). Hal ini terjadi pas suatu operasi butuh mengambil dua locks dan dua threads masing-masing udah ngambil salah satu dari locks tersebut, yang menyebabkan mereka saling menunggu satu sama lain selamanya. Kalau kita tertarik sama deadlocks, coba bikin program Rust yang punya sebuah deadlock; terus cari tahu soal strategi mitigasi deadlock buat mutexes di bahasa pemrograman apa pun lalu cobalah mengimplementasikan strategi itu di Rust. Dokumentasi API standard library buat Mutex<T> dan MutexGuard nawarin informasi yang berguna.

Kita bakal menuntaskan bab ini dengan ngomongin soal trait Send dan Sync dan gimana cara kita bisa memakainya bersama custom types (tipe kustom).