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

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:

  1. Musisz spróbować uzyskać blokadę przed użyciem danych.
  2. 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.