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:
- Kita harus mencoba mengambil (acquire) lock-nya sebelum memakai datanya.
- 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.
use std::sync::Mutex;
fn main() {
let m = Mutex::new(5);
{
let mut num = m.lock().unwrap();
*num = 6;
}
println!("m = {m:?}");
}
Mutex<T> di dalam konteks single-threaded buat kesederhanaanSama 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.
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());
}
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.
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());
}
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.
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());
}
Arc<T> buat ngebungkus Mutex<T> supaya bisa membagikan kepemilikan di beberapa threadsKode 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).