Перейти к содержимому

Многопоточность в 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());
}

Вывод:

Результат: 5
▶ Попробуй сам

counter.lock() ждёт, пока можно безопасно получить эксклюзивный доступ к значению, затем делается изменение, и блокировка снимается автоматически. Пять потоков увеличивают один и тот же счётчик, и благодаря Mutex они никогда не мешают друг другу: ты всегда получаешь 5, а не меньшее число из-за потерянного обновления.

Блокировать общие данные — один из способов, которым потоки сотрудничают. Другой популярный способ — вообще ничего не делить, а отправлять сообщения друг другу. Об этом страница Каналы.

Почему в Rust это называют «бесстрашной конкурентностью»

Заголовок раздела «Почему в Rust это называют «бесстрашной конкурентностью»»

В большинстве языков ничто не мешает забыть заблокировать Mutex или случайно передать между потоками ссылку, которую передавать нельзя. Компилятор Rust действительно это проверяет: код, который привёл бы к гонке данных (data race) между потоками, просто не скомпилируется. Это та же система владения и заимствования, которую ты изучал раньше, только применённая к потокам, а не к одной области видимости.

Сложи числа от 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 был здесь" в один общий 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