Использование потоков для одновременного выполнения кода
В большинстве современных операционных систем код выполняемой программы запускается в процессе, и операционная система управляет несколькими процессами одновременно. Внутри программы вы также можете иметь независимые части, выполняющиеся одновременно. Возможности, которые запускают эти независимые части, называются потоками. Например, веб-сервер может иметь несколько потоков, чтобы отвечать более чем на один запрос одновременно.
Разделение вычислений в вашей программе на несколько потоков для одновременного выполнения нескольких задач может улучшить производительность, но это также добавляет сложности. Поскольку потоки могут выполняться одновременно, нет никакой гарантии относительно порядка, в котором части вашего кода в разных потоках будут выполняться. Это может привести к проблемам, таким как:
- Гонки данных, при которой потоки обращаются к данным или ресурсам в непоследовательном порядке
- Взаимные блокировки, при которых два потока ждут друг друга, не давая ни одному из них продолжить выполнение
- Ошибки, которые происходят только в определенных ситуациях и трудно воспроизвести и исправить надёжно
Rust пытается смягчить негативные эффекты использования потоков, но программирование в многопоточном контексте всё ещё требует тщательного обдумывания и требует структуры кода, отличной от программ, выполняющихся в одном потоке.
Языки программирования реализуют потоки несколькими разными способами, и многие операционные системы предоставляют API, который язык может вызывать для создания новых потоков. Стандартная библиотека Rust использует модель реализации потоков 1:1, при которой программа использует один поток операционной системы на один поток языка. Существуют крейты, реализующие другие модели потоков, которые идут на компромиссы по сравнению с моделью 1:1. (Асинхронная система Rust, которую мы увидим в следующей главе, также предоставляет другой подход к конкурентности.)
Создание нового потока с помощью spawn
Чтобы создать новый поток, мы вызываем функцию thread::spawn и передаём ей замыкание (мы говорили о замыканиях в главе 13), содержащее код, который мы хотим выполнить в новом потоке. Пример в листинге 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 заставляют поток прекратить своё выполнение на короткое время, позволяя другому потоку выполниться. Потоки, вероятно, будут по очереди, но это не гарантировано: это зависит от того, как ваша операционная система планирует выполнение потоков. В этом запуске главный поток вывел текст первым, даже though оператор вывода из порождённого потока appears первым в коде. И даже though мы сказали порождённому потоку выводить до тех пор, пока i не станет 9, он добрался только до 5, прежде чем главный поток завершился.
Если вы запустите этот код и увидите вывод только из главного потока или не увидите перекрытия, попробуйте увеличить числа в диапазонах, чтобы создать больше возможностей для переключения между потоками операционной системой.
Ожидание завершения всех потоков с использованием join Handles
Код в листинге 16-1 не только останавливает порождённый поток преждевременно в большинстве случаев из-за завершения главного потока, но и потому, что нет гарантии порядка выполнения потоков, мы также не можем гарантировать, что порождённый поток получит возможность выполниться вообще!
Мы можем исправить проблему с тем, что порождённый поток не выполняется или завершается преждевременно, сохранив возвращаемое значение thread::spawn в переменной. Возвращаемый тип thread::spawn — это JoinHandle<T>. JoinHandle<T> — это владеющее значение, которое, когда мы вызываем метод join на нём, будет ждать завершения своего потока. Листинг 16-2 показывает, как использовать JoinHandle<T> потока, который мы создали в листинге 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(); }
JoinHandle<T> из thread::spawn для гарантии того, что поток выполнится до концаВызов join на handle блокирует текущий выполняющийся поток до тех пор, пока поток, представленный handle, не завершится. Блокировка потока означает, что этому потоку предотвращается выполнение работы или выход. Поскольку мы поместили вызов join после цикла for главного потока, запуск листинга 16-2 должен produce вывод, похожий на этот:
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, потому что замыкание тогда примет владение значениями, которые оно использует из окружения, тем самым передавая владение этими значениями из одного потока в другой. В “Захват окружения с помощью замыканий” в главе 13 мы обсуждали move в контексте замыканий. Теперь мы сосредоточимся больше на взаимодействии между move и thread::spawn.
Заметьте в листинге 16-1, что замыкание, которое мы передаём в thread::spawn, не принимает аргументов: мы не используем никакие данные из главного потока в коде порождённого потока. Чтобы использовать данные из главного потока в порождённом потоке, замыкание порождённого потока должно захватить значения, которые ему нужны. Листинг 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 всегда действительной.
Листинг 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();
}
v из главного потока, который удаляет vЕсли бы Rust позволил нам запустить этот код, есть вероятность, что порождённый поток будет немедленно помещён в фон без выполнения. Порождённый поток имеет ссылку на v внутри, но главный поток немедленно удаляет v, используя функцию drop, которую мы обсуждали в главе 15. Затем, когда порождённый поток начнёт выполняться, v больше не действителен, поэтому ссылка на него также недействительна. О нет!
Чтобы исправить ошибку компиляции в листинге 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 вывести, что он должен заимствовать значения. Изменение в листинге 16-3, показанное в листинге 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(); }
move для принудительного захвата замыканием владения используемыми значениямиМы можем быть склонны попробовать то же самое, чтобы исправить код в листинге 16-4, где главный поток вызвал drop, используя замыкание move. Однако это исправление не сработает, потому что то, что пытается сделать листинг 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
For more information about this error, try `rustc --explain E0382`.
error: could not compile `threads` (bin "threads") due to 1 previous error
Правила владения Rust снова нас спасли! Мы получили ошибку из кода в листинге 16-3, потому что Rust был консервативен и только заимствовал v для потока, что означало, что главный поток теоретически мог сделать ссылку порождённого потока недействительной. Сообщив Rust переместить владение v в порождённый поток, мы гарантируем Rust, что главный поток больше не будет использовать v. Если мы изменим листинг 16-4 таким же образом, мы тогда нарушим правила владения, когда попытаемся использовать v в главном потоке. Ключевое слово move переопределяет консервативный по умолчанию подход Rust к заимствованию; оно не позволяет нам нарушать правила владения.
Теперь, когда мы рассмотрели, что такое потоки и методы, предоставляемые API потоков, давайте посмотрим на некоторые ситуации, в которых мы можем использовать потоки.