Многопоточность в Rust
Конкурентность (concurrency) — выполнение нескольких задач одновременно — это место, где многие языки становятся по-настоящему опасными. Два потока, которые одновременно трогают одни и те же данные, могут испортить их так, что потом очень трудно найти причину. Именно такие ошибки и должны предотвращать правила заимствования, и эта защита напрямую распространяется на потоки.
Запускаем поток
Заголовок раздела «Запускаем поток»use std::thread;
fn main() { let handle = thread::spawn(|| { for i in 1..=3 { println!("поток: {i}"); } });
for i in 1..=3 { println!("main: {i}"); }
handle.join().unwrap(); // ждём, пока поток закончит}thread::spawn принимает замыкание и запускает его в новом потоке, параллельно со всем остальным. .join() блокирует выполнение, пока этот поток не закончит. Без него main может завершиться раньше, чем запущенный поток вообще начнёт работать.
Передаём данные в поток
Заголовок раздела «Передаём данные в поток»Замыкание, которое передаётся в thread::spawn, должно полностью владеть всеми данными, которые использует: оно может выполниться уже после того, как исходная область видимости закончилась. Чтобы этого добиться, добавь ключевое слово move:
use std::thread;
fn main() { let message = String::from("привет из потока");
let handle = thread::spawn(move || { println!("{message}"); });
handle.join().unwrap();}Вывод:
привет из потокаБез move Rust это не скомпилирует: он не может гарантировать, что message будет ещё жива, когда новый поток до неё доберётся. move передаёт владение message в замыкание. Это то же самое «перемещение», что ты видел на странице Владение, только данные уходят в поток, а не в другую переменную.
Безопасно делим данные между потоками
Заголовок раздела «Безопасно делим данные между потоками»Обычные ссылки между потоками не работают: они не могут жить дольше области видимости, в которой созданы. Поэтому для общих данных мы сочетаем две вещи, с которыми ты уже знаком: Arc (со страницы Умные указатели) для совместного владения и Mutex, который гарантирует, что с данными в каждый момент работает только один поток:
use std::sync::{Arc, Mutex};use std::thread;
fn main() { let counter = Arc::new(Mutex::new(0)); let mut handles = vec![];
for _ in 0..5 { 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!("Результат: {}", *counter.lock().unwrap());}Вывод:
Результат: 5counter.lock() ждёт, пока можно безопасно получить эксклюзивный доступ к значению, затем делается изменение, и блокировка снимается автоматически. Пять потоков увеличивают один и тот же счётчик, и благодаря Mutex они никогда не мешают друг другу: ты всегда получаешь 5, а не меньшее число из-за потерянного обновления.
Блокировать общие данные — один из способов, которым потоки сотрудничают. Другой популярный способ — вообще ничего не делить, а отправлять сообщения друг другу. Об этом страница Каналы.
Почему в Rust это называют «бесстрашной конкурентностью»
Заголовок раздела «Почему в Rust это называют «бесстрашной конкурентностью»»В большинстве языков ничто не мешает забыть заблокировать Mutex или случайно передать между потоками ссылку, которую передавать нельзя. Компилятор Rust действительно это проверяет: код, который привёл бы к гонке данных (data race) между потоками, просто не скомпилируется. Это та же система владения и заимствования, которую ты изучал раньше, только применённая к потокам, а не к одной области видимости.
Практика
Заголовок раздела «Практика»1. Два потока, одна сумма
Заголовок раздела «1. Два потока, одна сумма»Сложи числа от 1 до 100 с помощью двух потоков: один суммирует от 1 до 50, другой — от 51 до 100. Потом main складывает оба ответа. Подсказка: замыкание потока может вернуть значение, а handle.join().unwrap() отдаст его тебе.
Показать решение
use std::thread;
fn main() { let first = thread::spawn(|| { let total: u32 = (1..=50).sum(); total }); let second = thread::spawn(|| { let total: u32 = (51..=100).sum(); total });
let a = first.join().unwrap(); let b = second.join().unwrap(); println!("{a} + {b} = {}", a + b);}Вывод:
1275 + 3775 = 5050Эти замыкания не используют внешних переменных, поэтому move им не нужен. Оба потока работают одновременно, а join() дожидается каждого и возвращает его результат.
2. Все расписываются в гостевой книге
Заголовок раздела «2. Все расписываются в гостевой книге»Запусти три потока. Каждый добавляет строку вроде "поток 2 был здесь" в один общий Vec<String>. Когда все потоки закончат, выведи строки в отсортированном порядке. Подсказка: используй Arc<Mutex<Vec<String>>>, как в примере со счётчиком, а в конце отсортируй копию — потоки могут закончить в любом порядке.
Показать решение
use std::sync::{Arc, Mutex};use std::thread;
fn main() { let book = Arc::new(Mutex::new(Vec::new())); let mut handles = vec![];
for id in 1..=3 { let book = Arc::clone(&book); handles.push(thread::spawn(move || { book.lock().unwrap().push(format!("поток {id} был здесь")); })); }
for handle in handles { handle.join().unwrap(); }
let mut lines = book.lock().unwrap().clone(); lines.sort(); for line in lines { println!("{line}"); }}Вывод:
поток 1 был здесьпоток 2 был здесьпоток 3 был здесьУбери строку lines.sort() и запусти программу несколько раз: порядок может меняться. Сортировка нужна, чтобы вывод каждый раз был одинаковым.
thread::spawn(closure)запускает замыкание в новом потоке;.join()ждёт, пока он закончит.- Замыканию, которое передаётся в поток, обычно нужен
move, чтобы полностью забрать владение данными, которые оно использует. - Делить данные между потоками помогает
Arc(совместное владение) вместе сMutex(эксклюзивный доступ):counter.lock()даёт безопасный доступ к значению по одному потоку за раз. - Код, который привёл бы к гонке данных, компилятор отвергает ещё при компиляции, а не во время работы.
Проверь себя
Заголовок раздела «Проверь себя»Проверь себя
1. Почему замыканию, переданному в thread::spawn, обычно нужно ключевое слово move?
2. Для чего нужен Arc<Mutex<T>>?
3. Что может пойти не так, если забыть вызвать .join() у дескриптора потока?
4. Почему порядок строк «main:» и «поток:» может меняться между запусками одной и той же программы?
Результат: 0 / 4
Что почитать
Заголовок раздела «Что почитать»- Использование потоков для параллельного выполнения кода — книга «Язык программирования Rust» (перевод на русский):
spawn,joinи замыкания сmove, с примерами того, как перемешивается вывод. - Параллелизм с общим состоянием — книга «Язык программирования Rust» (перевод на русский):
MutexиArcподробно, в том числе почемуRcнельзя отправить в другой поток. std::thread— документация стандартной библиотеки (на английском): всё о потоках, включая их имена и scoped-потоки.Mutex— документация стандартной библиотеки (на английском): как работает блокировка и что значит «отравление» (poisoning), если поток паникует, держа блокировку.- Потоки — Rust на примерах (перевод на русский): короткий запускаемый пример, который создаёт несколько потоков и дожидается их.