Використання потоків для одночасного виконання коду
У більшості сучасних операційних систем виконуваний код програми запускається в процесі, і операційна система керуватиме кількома процесами одночасно. У межах програми ви також можете мати незалежні частини, які виконуються одночасно. Функції, що запускають ці незалежні частини, називаються потоками. Наприклад, вебсервер може мати кілька потоків, щоб він міг відповідати на більше ніж один запит одночасно.
Розбиття обчислення у вашій програмі на кілька потоків для одночасного виконання кількох завдань може покращити продуктивність, але це також додає складності. Оскільки потоки можуть виконуватися одночасно, немає вбудованої гарантії щодо порядку, у якому частини вашого коду в різних потоках будуть виконуватися. Це може призвести до проблем, таких як:
- Стан гонки даних, коли потоки звертаються до даних або ресурсів у непослідовному порядку
- Взаємне блокування, коли два потоки чекають один на одного, не даючи обом потокам продовжити виконання
- Помилки, які трапляються лише в певних ситуаціях і їх важко надійно відтворити та виправити
Rust намагається пом’якшити негативні наслідки використання потоків, але програмування в багатопотоковому контексті все одно потребує уважного обмірковування і вимагає структури коду, яка відрізняється від програм, що виконуються в одному потоці.
Мови програмування реалізують потоки кількома різними способами, і багато операційних систем надають API, який мова програмування може викликати для створення нових потоків. Стандартна бібліотека Rust використовує модель реалізації потоків 1:1, за якої програма використовує один потік операційної системи на один потік мови. Існують крейти, які реалізують інші моделі потоків, що роблять інші компроміси порівняно з моделлю 1:1. (Система async Rust, яку ми побачимо в наступному розділі, також надає інший підхід до конкурентності.)
Створення нового потоку за допомогою spawn
Щоб створити новий потік, ми викликаємо функцію thread::spawn і передаємо
їй замикання (ми говорили про замикання в розділі 13), що містить код,
який ми хочемо запустити в новому потоці. Приклад у Listing 16-1 виводить
частину тексту з головного потоку та інший текст із нового потоку.
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));
}
}
Зверніть увагу, що коли головний потік програми Rust завершується, усі створені потоки завершуються, незалежно від того, чи встигли вони завершити виконання. Вивід цієї програми може трохи відрізнятися щоразу, але він виглядатиме приблизно так:
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!
Виклики thread::sleep змушують потік зупинити своє виконання на короткий
час, дозволяючи іншому потоку виконуватися. Потоки, ймовірно, будуть
чергуватися, але це не гарантується: це залежить від того, як ваша операційна
система планує потоки. У цьому запуску головний потік вивів першим, хоча
оператор виведення з створеного потоку з’являється в коді першим. І хоча ми
сказали створеному потоку виводити до i 9, він дійшов лише до 5
перед тим, як головний потік завершив роботу.
Якщо ви запустите цей код і побачите лише вивід із головного потоку або не побачите жодного накладання, спробуйте збільшити числа в діапазонах, щоб створити більше можливостей для операційної системи перемикатися між потоками.
Очікування завершення всіх потоків
Код у Listing 16-1 не лише передчасно зупиняє створений потік більшу частину часу через завершення головного потоку, але й тому, що немає гарантії щодо порядку, у якому виконуються потоки, ми також не можемо гарантувати, що створений потік взагалі встигне виконатися!
Ми можемо виправити проблему з тим, що створений потік не виконується або
завершується передчасно, зберігши повернене значення thread::spawn у
змінній. Повернений тип thread::spawn — JoinHandle<T>. JoinHandle<T> —
це власницьке значення, яке, коли ми викликаємо на ньому метод join, буде
чекати завершення свого потоку. Listing 16-2 показує, як використовувати
JoinHandle<T> потоку, який ми створили в Listing 16-1, і як викликати join,
щоб переконатися, що створений потік завершиться до виходу з 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();
}
Виклик join на обробнику блокує потік, який зараз виконується, доки потік,
представлений обробником, не завершиться. Блокування потоку означає, що
цьому потоку заборонено виконувати роботу або завершуватися. Оскільки ми
помістили виклик join після циклу for головного потоку, запуск Listing 16-2
має дати вивід, подібний до цього:
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!
Два потоки продовжують чергуватися, але головний потік чекає через
виклик handle.join() і не завершується, доки створений потік не закінчить
роботу.
Але подивімося, що станеться, коли ми натомість перемістимо handle.join()
перед цикл for у 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));
}
});
handle.join().unwrap();
for i in 1..5 {
println!("hi number {i} from the main thread!");
thread::sleep(Duration::from_millis(1));
}
}
Головний потік чекатиме, поки створений потік завершиться, а потім виконає
свій цикл for, тож вивід більше не буде перемішаним, як показано тут:
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!
Невеликі деталі, такі як місце, де викликається join, можуть впливати на
те, чи будуть ваші потоки виконуватися одночасно.
Використання замикань move з потоками
Ми часто використовуватимемо ключове слово move із замиканнями, переданими
до thread::spawn, тому що тоді замикання бере у власність значення, які
воно використовує з оточення, тим самим передаючи власність цих значень з
одного потоку до іншого. У “Capturing References or Moving Ownership” у розділі 13 ми обговорювали move у контексті замикань. Тепер ми
зосередимося більше на взаємодії між move і thread::spawn.
Зверніть увагу в Listing 16-1, що замикання, яке ми передаємо до
thread::spawn, не приймає жодних аргументів: ми не використовуємо жодних
даних із головного потоку в коді створеного потоку. Щоб використати дані з
головного потоку в створеному потоці, замикання створеного потоку має
захопити потрібні йому значення. Listing 16-3 показує спробу створити вектор у
головному потоці та використати його в створеному потоці. Однак це ще не
спрацює, як ви побачите за мить.
use std::thread;
fn main() {
let v = vec![1, 2, 3];
let handle = thread::spawn(|| {
println!("Here's a vector: {v:?}");
});
handle.join().unwrap();
}
Замикання використовує v, тож воно захопить v і зробить його частиною
середовища замикання. Оскільки thread::spawn запускає це замикання в новому
потоці, ми мали б мати змогу звертатися до v у тому новому потоці. Але коли
ми компілюємо цей приклад, отримуємо таку помилку:
$ 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 виводить, як саме захопити v, і оскільки println! потрібне лише
посилання на v, замикання намагається позичити v. Однак є проблема: Rust
не може визначити, як довго працюватиме створений потік, тож він не знає, чи
посилання на v завжди буде дійсним.
Listing 16-4 надає сценарій, у якому ймовірніше буде посилання на v, яке
не буде дійсним.
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();
}
Якби Rust дозволив нам запустити цей код, існувала б можливість, що створений
потік буде негайно переведено у фоновий режим без жодного виконання. У
створеного потоку всередині є посилання на v, але головний потік негайно
скидає v, використовуючи функцію drop, яку ми обговорювали в розділі 15.
Потім, коли створений потік почне виконуватися, v уже не буде дійсним, тож
посилання на нього також буде недійсним. Ой-ой!
Щоб виправити помилку компілятора в Listing 16-3, ми можемо скористатися порадами з повідомлення про помилку:
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 || {
| ++++
Додавши ключове слово move перед замиканням, ми змушуємо замикання
взяти у власність значення, які воно використовує, замість того, щоб Rust
вивів, що воно має позичати ці значення. Зміна до Listing 16-3, показана в
Listing 16-5, скомпілюється і працюватиме так, як ми задумали.
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();
}
Ми могли б спробувати те саме, щоб виправити код у Listing 16-4, де головний
потік викликав drop, використавши замикання move. Однак це виправлення не
спрацює, тому що те, що намагається зробити Listing 16-4, заборонено з іншої
причини. Якби ми додали move до замикання, ми б перемістили v у
середовище замикання, і більше не могли б викликати drop для нього в
головному потоці. Замість цього ми отримали б таку помилку компілятора:
$ 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
Правила власності Rust знову нас врятували! Ми отримали помилку з коду в
Listing 16-3, тому що Rust поводився обережно і лише позичав v для потоку,
що означало, що головний потік теоретично міг зробити посилання створеного
потоку недійсним. Сказавши Rust перемістити власність v до створеного
потоку, ми гарантуємо Rust, що головний потік більше не використовуватиме
v. Якщо ми змінимо Listing 16-4 так само, то тоді порушимо правила
власності, коли спробуємо використати v у головному потоці. Ключове слово
move скасовує обережне значення Rust за замовчуванням щодо запозичення; воно
не дає нам порушити правила власності.
Тепер, коли ми розглянули, що таке потоки і які методи надає API потоків, давайте подивимося на деякі ситуації, у яких ми можемо використовувати потоки.