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.