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

Używanie wątków do jednoczesnego uruchamiania kodu

W większości obecnych systemów operacyjnych kod wykonywanego programu działa w procesie, a system operacyjny zarządza wieloma procesami jednocześnie. Wewnątrz programu możesz mieć również niezależne części, które działają jednocześnie. Funkcje, które uruchamiają te niezależne części, nazywane są wątkami. Na przykład, serwer webowy może mieć wiele wątków, aby mógł jednocześnie odpowiadać na więcej niż jedno żądanie.

Podzielenie obliczeń w programie na wiele wątków w celu jednoczesnego uruchamiania wielu zadań może poprawić wydajność, ale także zwiększa złożoność. Ponieważ wątki mogą działać jednocześnie, nie ma inherentnej gwarancji co do kolejności, w jakiej będą działać części kodu na różnych wątkach. Może to prowadzić do problemów, takich jak:

  • Wyścigi danych, w których wątki uzyskują dostęp do danych lub zasobów w niekonsekwentnej kolejności
  • Zakleszczenia, w których dwa wątki czekają na siebie nawzajem, uniemożliwiając kontynuowanie obu wątków
  • Błędy, które występują tylko w określonych sytuacjach i są trudne do niezawodnego odtworzenia i naprawy

Rust próbuje złagodzić negatywne skutki używania wątków, ale programowanie w kontekście wielowątkowym nadal wymaga starannego przemyślenia i innej struktury kodu niż w programach działających w pojedynczym wątku.

Języki programowania implementują wątki na kilka różnych sposobów, a wiele systemów operacyjnych zapewnia API, które język programowania może wywoływać w celu tworzenia nowych wątków. Biblioteka standardowa Rust używa modelu implementacji wątków 1:1, w którym program używa jednego wątku systemu operacyjnego na jeden wątek językowy. Istnieją crate’y, które implementują inne modele wątkowania, które dokonują innych kompromisów w stosunku do modelu 1:1. (System asynchroniczny Rust, który zobaczymy w następnym rozdziale, zapewnia również inne podejście do współbieżności.)

Tworzenie nowego wątku za pomocą spawn

Aby utworzyć nowy wątek, wywołujemy funkcję thread::spawn i przekazujemy jej domknięcie (o domknięciach mówiliśmy w Rozdziale 13) zawierające kod, który chcemy uruchomić w nowym wątku. Przykład w Listingu 16-1 wypisuje tekst z głównego wątku i inny tekst z nowego wątku.

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));
    }
}

Zauważ, że gdy główny wątek programu Rust zakończy działanie, wszystkie utworzone wątki zostają wyłączone, niezależnie od tego, czy zakończyły działanie. Wynik tego programu może być za każdym razem nieco inny, ale będzie wyglądał podobnie do następującego:

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!

Wywołania thread::sleep zmuszają wątek do zatrzymania jego wykonania na krótki okres, pozwalając na uruchomienie innego wątku. Wątki prawdopodobnie będą działać naprzemiennie, ale nie jest to gwarantowane: Zależy to od tego, jak system operacyjny planuje wątki. W tym uruchomieniu główny wątek wypisał się jako pierwszy, mimo że instrukcja print z utworzonego wątku pojawia się pierwsza w kodzie. I chociaż kazaliśmy utworzonemu wątkowi wypisywać, dopóki i nie będzie równe 9, doszedł tylko do 5, zanim główny wątek się wyłączył.

Jeśli uruchomisz ten kod i zobaczysz tylko wynik z głównego wątku, lub nie zobaczysz żadnych nakładek, spróbuj zwiększyć liczby w zakresach, aby stworzyć więcej możliwości dla systemu operacyjnego do przełączania między wątkami.

Czekanie na zakończenie wszystkich wątków

Kod z Listingu 16-1 nie tylko zatrzymuje utworzony wątek przedwcześnie w w większości przypadków z powodu zakończenia głównego wątku, ale ponieważ nie ma gwarancji co do kolejności, w jakiej wątki działają, nie możemy również zagwarantować, że utworzony wątek w ogóle się uruchomi!

Problem przedwczesnego zakończenia lub braku uruchomienia utworzonego wątku możemy naprawić, zapisując wartość zwracaną przez thread::spawn w zmiennej. Typem zwracanym przez thread::spawn jest JoinHandle<T>. JoinHandle<T> jest wartością własnościową, która po wywołaniu na niej metody join będzie czekała na zakończenie swojego wątku. Listing 16-2 pokazuje, jak użyć JoinHandle<T> z utworzonego w Listingu 16-1 wątku i jak wywołać join, aby upewnić się, że utworzony wątek zakończy się przed zakończeniem main.

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();
}

Wywołanie join na uchwycie blokuje aktualnie działający wątek, uniemożliwiając mu wykonywanie pracy lub zakończenie, dopóki wątek reprezentowany przez uchwyt się nie zakończy. Blokowanie wątku oznacza, że wątek jest uniemożliwiony wykonanie pracy lub wyjścia. Ponieważ umieściliśmy wywołanie join po pętli for głównego wątku, uruchomienie Listingu 16-2 powinno wygenerować wynik podobny do tego:

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!

Dwa wątki nadal działają naprzemiennie, ale główny wątek czeka z powodu wywołania handle.join() i nie kończy działania, dopóki utworzony wątek się nie zakończy.

Ale zobaczmy, co się stanie, gdy zamiast tego przeniesiemy handle.join() przed pętlę for w main, tak:

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));
    }
}

Główny wątek poczeka na zakończenie utworzonego wątku, a następnie uruchomi swoją pętlę for, więc wyniki nie będą już przeplatane, jak pokazano tutaj:

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!

Małe detale, takie jak miejsce wywołania join, mogą wpływać na to, czy wątki działają jednocześnie.

Używanie domknięć move z wątkami

Często będziemy używać słowa kluczowego move z domknięciami przekazywanymi do thread::spawn, ponieważ domknięcie przejmie wtedy własność wartości, których używa ze środowiska, przenosząc w ten sposób własność tych wartości z jednego wątku do drugiego. W sekcji „Przechwytywanie referencji lub przenoszenie własności” w Rozdziale 13 omówiliśmy move w kontekście domknięć. Teraz skupimy się bardziej na interakcji między move a thread::spawn.

Zauważ w Listingu 16-1, że domknięcie, które przekazujemy do thread::spawn, nie przyjmuje żadnych argumentów: Nie używamy żadnych danych z głównego wątku w kodzie utworzonego wątku. Aby użyć danych z głównego wątku w utworzonym wątku, domknięcie utworzonego wątku musi przechwycić wartości, których potrzebuje. Listing 16-3 pokazuje próbę utworzenia wektora w głównym wątku i użycia go w utworzonym wątku. Jednak to jeszcze nie zadziała, jak zobaczysz za chwilę.

use std::thread;

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

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

    handle.join().unwrap();
}

Domknięcie używa v, więc przechwyci v i uczyni je częścią środowiska domknięcia. Ponieważ thread::spawn uruchamia to domknięcie w nowym wątku, powinniśmy mieć dostęp do v w tym nowym wątku. Ale kiedy kompilujemy ten przykład, otrzymujemy następujący błąd:

$ 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 wnioskuje, jak przechwycić v, a ponieważ println! potrzebuje tylko referencji do v, domknięcie próbuje pożyczyć v. Jest jednak problem: Rust nie jest w stanie określić, jak długo będzie działać utworzony wątek, więc nie wie, czy referencja do v zawsze będzie ważna.

Listing 16-4 przedstawia scenariusz, w którym referencja do v z większym prawdopodobieństwem nie będzie ważna.

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();
}

Gdyby Rust pozwolił nam uruchomić ten kod, istniałaby możliwość, że utworzony wątek zostałby natychmiast przeniesiony do tła bez uruchomienia. Utworzony wątek ma w środku referencję do v, ale główny wątek natychmiast usuwa v używając funkcji drop, którą omówiliśmy w Rozdziale 15. Wtedy, gdy utworzony wątek zacznie się wykonywać, v nie jest już ważne, więc referencja do niego również jest nieważna. Och nie!

Aby naprawić błąd kompilacji w Listingu 16-3, możemy skorzystać z porady z komunikatu o błędzie:

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 || {
  |                                ++++

Dodając słowo kluczowe move przed domknięciem, zmuszamy domknięcie do przejęcia własności wartości, których używa, zamiast pozwalać Rustowi wnioskować, że powinno ono pożyczyć te wartości. Modyfikacja Listingu 16-3 przedstawiona w Listingu 16-5 skompiluje się i zadziała zgodnie z naszym zamierzeniem.

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();
}

Możemy być kuszeni, aby spróbować tego samego, aby naprawić kod z Listingu 16-4, gdzie główny wątek wywołał drop za pomocą domknięcia move. Jednakże, ta poprawka nie zadziała, ponieważ to, co Listing 16-4 próbuje zrobić, jest niedozwolone z innego powodu. Gdybyśmy dodali move do domknięcia, przenieślibyśmy v do środowiska domknięcia i nie moglibyśmy już wywoływać na v drop w głównym wątku. Zamiast tego otrzymalibyśmy błąd kompilacji:

$ 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

Zasady własności Rust znów nas uratowały! Otrzymaliśmy błąd z kodu w Listingu 16-3, ponieważ Rust był konserwatywny i tylko pożyczał v dla wątku, co oznaczało, że główny wątek teoretycznie mógłby unieważnić referencję utworzonego wątku. Mówiąc Rustowi, aby przeniósł własność v do utworzonego wątku, gwarantujemy Rustowi, że główny wątek nie będzie już używał v. Jeśli zmienimy Listing 16-4 w ten sam sposób, naruszamy wtedy zasady własności, gdy próbujemy użyć v w głównym wątku. Słowo kluczowe move nadpisuje konserwatywną domyślną pożyczkę Rust; nie pozwala nam naruszać zasad własności.

Teraz, gdy omówiliśmy, czym są wątki i metody dostarczane przez API wątków, przyjrzyjmy się kilku sytuacjom, w których możemy używać wątków.