Współbieżność ze współdzielonym stanem
Przekazywanie wiadomości to dobry sposób na obsługę współbieżności, ale nie jedyny. Inną metodą byłoby, aby wiele wątków uzyskiwało dostęp do tych samych współdzielonych danych. Ponownie rozważmy tę część sloganu z dokumentacji języka Go: „Nie komunikuj się poprzez współdzielenie pamięci.”
Jak wyglądałaby komunikacja poprzez współdzielenie pamięci? Ponadto, dlaczego entuzjaści przekazywania wiadomości ostrzegaliby przed używaniem współdzielenia pamięci?
Pod pewnym względem kanały w każdym języku programowania są podobne do pojedynczej własności, ponieważ po przeniesieniu wartości przez kanał nie powinno się już więcej używać tej wartości. Współbieżność oparta na współdzielonej pamięci jest jak wielokrotna własność: Wiele wątków może uzyskiwać dostęp do tego samego miejsca w pamięci w tym samym czasie. Jak widziałeś w Rozdziale 15, gdzie wskaźniki sprytne umożliwiły wielokrotną własność, wielokrotna własność może zwiększyć złożoność, ponieważ ci różni właściciele wymagają zarządzania. System typów Rust i zasady własności znacznie pomagają w prawidłowym zarządzaniu. Na przykład, przyjrzyjmy się muteksom, jednej z najpopularniejszych prymitywów współbieżności dla współdzielonej pamięci.
Kontrolowanie dostępu za pomocą muteksów
Muteks to skrót od mutual exclusion (wzajemne wykluczenie), co oznacza, że muteks pozwala tylko jednemu wątkowi na dostęp do danych w danym momencie. Aby uzyskać dostęp do danych w muteksie, wątek musi najpierw zasygnalizować, że chce uzyskać dostęp, prosząc o nabycie blokady muteksu. Blokada to struktura danych, która jest częścią muteksu i śledzi, kto aktualnie ma wyłączny dostęp do danych. Dlatego muteks jest opisywany jako ochraniający dane, które przechowuje, za pomocą systemu blokowania.
Muteksy mają reputację trudnych w użyciu, ponieważ trzeba pamiętać o dwóch zasadach:
- Musisz spróbować uzyskać blokadę przed użyciem danych.
- Kiedy skończysz korzystać z danych chronionych przez muteks, musisz zwolnić blokadę, aby inne wątki mogły ją uzyskać.
Na przykład, wyobraź sobie panel dyskusyjny na konferencji z tylko jednym mikrofonem. Zanim panelista będzie mógł mówić, musi zapytać lub zasygnalizować, że chce użyć mikrofonu. Kiedy dostanie mikrofon, może mówić tak długo, jak chce, a następnie przekazać mikrofon kolejnemu panelistowi, który poprosi o mówienie. Jeśli panelista zapomni oddać mikrofon, gdy skończy z niego korzystać, nikt inny nie będzie mógł mówić. Jeśli zarządzanie wspólnym mikrofonem pójdzie źle, panel nie zadziała zgodnie z planem!
Zarządzanie muteksami może być niezwykle trudne do prawidłowego wykonania, dlatego tak wiele osób jest entuzjastycznie nastawionych do kanałów. Jednak dzięki systemowi typów i zasadom własności Rust, nie można pomylić się z blokowaniem i odblokowywaniem.
API Mutex<T>
Jako przykład użycia muteksu, zacznijmy od użycia muteksu w kontekście jednowątkowym, jak pokazano w Listingu 16-12.
use std::sync::Mutex;
fn main() {
let m = Mutex::new(5);
{
let mut num = m.lock().unwrap();
*num = 6;
}
println!("m = {m:?}");
}
Podobnie jak w przypadku wielu typów, tworzymy Mutex<T> za pomocą
skojarzonej funkcji new. Aby uzyskać dostęp do danych wewnątrz muteksu,
używamy metody lock, aby uzyskać blokadę. To wywołanie zablokuje bieżący
wątek, tak aby nie mógł on wykonywać żadnej pracy, dopóki nie nadejdzie nasza
kolej na uzyskanie blokady.
Wywołanie lock zakończyłoby się niepowodzeniem, gdyby inny wątek trzymający
blokadę wywołał panikę. W takim przypadku nikt nigdy nie mógłby uzyskać blokady,
dlatego zdecydowaliśmy się na unwrap, aby ten wątek wywołał panikę, jeśli
znajdziemy się w takiej sytuacji.
Po uzyskaniu blokady, możemy traktować zwróconą wartość, nazwaną w tym przypadku
num, jako mutowalną referencję do wewnętrznych danych. System typów
gwarantuje, że uzyskujemy blokadę przed użyciem wartości w m. Typ m to
Mutex<i32>, a nie i32, więc musimy wywołać lock, aby móc użyć wartości
i32. Nie możemy zapomnieć; system typów w przeciwnym razie nie pozwoli nam
na dostęp do wewnętrznej wartości i32.
Wywołanie lock zwraca typ nazwany MutexGuard, opakowany w LockResult,
który obsłużyliśmy wywołaniem unwrap. Typ MutexGuard implementuje Deref,
aby wskazywać na nasze wewnętrzne dane; typ ten ma również implementację Drop,
która automatycznie zwalnia blokadę, gdy MutexGuard wyjdzie poza zakres,
co dzieje się na końcu wewnętrznego zakresu. W rezultacie nie ryzykujemy
zapomnienia o zwolnieniu blokady i zablokowania muteksu przed użyciem przez
inne wątki, ponieważ zwolnienie blokady odbywa się automatycznie.
Po zwolnieniu blokady możemy wypisać wartość muteksu i zobaczyć, że udało nam
się zmienić wewnętrzną wartość i32 na 6.
Współdzielony dostęp do Mutex<T>
Teraz spróbujmy współdzielić wartość między wieloma wątkami za pomocą Mutex<T>.
Uruchomimy 10 wątków i każdy z nich zwiększy wartość licznika o 1, więc licznik
wzrośnie od 0 do 10. Przykład w Listingu 16-13 spowoduje błąd kompilacji, a my
wykorzystamy ten błąd, aby dowiedzieć się więcej o używaniu Mutex<T> i o tym,
jak Rust pomaga nam używać go poprawnie.
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());
}
Tworzymy zmienną counter do przechowywania i32 wewnątrz Mutex<T>, tak jak
zrobiliśmy to w Listingu 16-12. Następnie tworzymy 10 wątków, iterując po
zakresie liczb. Używamy thread::spawn i przekazujemy wszystkim wątkom to
samo domknięcie: takie, które przenosi licznik do wątku, uzyskuje blokadę na
Mutex<T> poprzez wywołanie metody lock, a następnie dodaje 1 do wartości w
muteksie. Gdy wątek zakończy wykonywanie swojego domknięcia, num wyjdzie
poza zakres i zwolni blokadę, aby inny wątek mógł ją uzyskać.
W głównym wątku zbieramy wszystkie uchwyty join. Następnie, jak to zrobiliśmy
w Listingu 16-2, wywołujemy join na każdym uchwycie, aby upewnić się, że
wszystkie wątki się zakończyły. W tym momencie główny wątek uzyska blokadę i
wypisze wynik tego programu.
Sugerowaliśmy, że ten przykład się nie skompiluje. Teraz dowiemy się, dlaczego!
$ 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
Komunikat o błędzie wskazuje, że wartość counter została przeniesiona w
poprzedniej iteracji pętli. Rust informuje nas, że nie możemy przenieść własności
blokady counter do wielu wątków. Naprawmy błąd kompilacji, używając metody
wielokrotnej własności, którą omówiliśmy w Rozdziale 15.
Wielokrotna własność z wieloma wątkami
W Rozdziale 15, nadaliśmy wartość wielu właścicielom, używając wskaźnika
sprytnego Rc<T> do stworzenia wartości zliczanej referencjami. Zróbmy to samo
tutaj i zobaczmy, co się stanie. Opakujemy Mutex<T> w Rc<T> w Listingu 16-14
i sklonujemy Rc<T> przed przeniesieniem własności do wątku.
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());
}
Ponownie kompilujemy i otrzymujemy… inne błędy! Kompilator wiele nas uczy:
$ 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, ten komunikat o błędzie jest bardzo rozwlekły! Oto najważniejsza część, na
której należy się skupić: `Rc<Mutex<i32>>` cannot be sent between threads safely. Kompilator podaje nam również przyczynę: the trait `Send` is not implemented for `Rc<Mutex<i32>>`. O Send będziemy rozmawiać w następnej
sekcji: Jest to jedna z cech, która zapewnia, że typy, których używamy z
wątkami, są przeznaczone do użytku w sytuacjach współbieżnych.
Niestety, Rc<T> nie jest bezpieczny do współdzielenia między wątkami. Kiedy
Rc<T> zarządza licznikiem referencji, dodaje do licznika dla każdego wywołania
clone i odejmuje od licznika, gdy każdy klon zostanie usunięty. Ale nie używa
żadnych prymitywów współbieżności, aby upewnić się, że zmiany w liczniku nie
mogą być przerwane przez inny wątek. Może to prowadzić do błędnych liczników
— subtelnych błędów, które z kolei mogą prowadzić do wycieków pamięci lub
przedwczesnego usunięcia wartości. Potrzebujemy typu, który jest dokładnie
taki jak Rc<T>, ale który dokonuje zmian w liczniku referencji w sposób
bardzo bezpieczny dla wątków.
Atomowe zliczanie referencji za pomocą Arc<T>
Na szczęście Arc<T> jest typem podobnym do Rc<T>, który jest bezpieczny
w użyciu w sytuacjach współbieżnych. Litera a oznacza atomic, co oznacza,
że jest to typ atomowo zliczający referencje. Atomy to dodatkowy rodzaj
prymitywu współbieżności, którego nie będziemy szczegółowo omawiać: Więcej
szczegółów można znaleźć w dokumentacji biblioteki standardowej dla
std::sync::atomic. W tym momencie wystarczy wiedzieć,
że atomy działają jak typy prymitywne, ale są bezpieczne do współdzielenia
między wątkami.
Możesz się wtedy zastanawiać, dlaczego wszystkie typy prymitywne nie są
atomowe i dlaczego typy biblioteki standardowej nie są domyślnie
zimplementowane do używania Arc<T>. Powodem jest to, że bezpieczeństwo
wątkowe wiąże się z karą wydajnościową, którą chcesz ponieść tylko wtedy, gdy
rzeczywiście tego potrzebujesz. Jeśli wykonujesz operacje na wartościach
jednowątkowo, twój kod może działać szybciej, jeśli nie musi egzekwować
gwarancji zapewnianych przez atomy.
Wróćmy do naszego przykładu: Arc<T> i Rc<T> mają to samo API, więc
naprawiamy nasz program, zmieniając linię use, wywołanie new i wywołanie
clone. Kod w Listingu 16-15 ostatecznie skompiluje się i uruchomi.
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());
}
Ten kod wypisze następujące:
Result: 10
Udało się! Policzyliśmy od 0 do 10, co może nie wydawać się zbyt imponujące,
ale wiele nas nauczyło o Mutex<T> i bezpieczeństwie wątkowym. Możesz również
wykorzystać strukturę tego programu do bardziej skomplikowanych operacji niż
samo zwiększanie licznika. Używając tej strategii, możesz podzielić obliczenia
na niezależne części, rozdzielić te części między wątki, a następnie użyć
Mutex<T>, aby każdy wątek aktualizował ostateczny wynik swoją częścią.
Zauważ, że jeśli wykonujesz proste operacje numeryczne, istnieją typy
prostsze niż Mutex<T> udostępniane przez moduł
std::sync::atomic biblioteki standardowej. Typy te
zapewniają bezpieczny, współbieżny, atomowy dostęp do typów prymitywnych.
Wybraliśmy użycie Mutex<T> z typem prymitywnym w tym przykładzie, abyśmy mogli
skupić się na tym, jak działa Mutex<T>.
Porównanie RefCell<T>/Rc<T> i Mutex<T>/Arc<T>
Być może zauważyłeś, że counter jest niemutowalny, ale mogliśmy uzyskać do
wartości w nim mutowalną referencję; oznacza to, że Mutex<T> zapewnia
mutowalność wewnętrzną, podobnie jak rodzina Cell. W ten sam sposób, w jaki
wykorzystaliśmy RefCell<T> w Rozdziale 15, aby umożliwić nam mutowanie
zawartości wewnątrz Rc<T>, używamy Mutex<T> do mutowania zawartości wewnątrz
Arc<T>.
Kolejny szczegół do odnotowania to to, że Rust nie jest w stanie ochronić Cię
przed wszystkimi rodzajami błędów logicznych, gdy używasz Mutex<T>. Przypomnij
sobie z Rozdziału 15, że użycie Rc<T> wiązało się z ryzykiem tworzenia cykli
referencji, gdzie dwie wartości Rc<T> odwołują się do siebie nawzajem,
powodując wycieki pamięci. Podobnie, Mutex<T> wiąże się z ryzykiem tworzenia
zakleszczeń. Dzieje się to, gdy operacja musi zablokować dwa zasoby, a dwa
wątki uzyskały po jednej blokadzie, powodując, że czekają na siebie nawzajem w
nieskończoność. Jeśli interesują Cię zakleszczenia, spróbuj stworzyć program
w Rust, który ma zakleszczenie; następnie zbadaj strategie łagodzenia
zakleszczeń dla muteksów w dowolnym języku i spróbuj je zaimplementować w Rust.
Dokumentacja API biblioteki standardowej dla Mutex<T> i MutexGuard oferuje
przydatne informacje.
Ukończymy ten rozdział, mówiąc o cechach Send i Sync oraz o tym, jak możemy
ich używać z niestandardowymi typami.