Потоки, передача владения, Send и Sync
Потоки в Rust — это обычные потоки ОС, но компилятор не даст вам передать в них то, что нельзя передавать безопасно.
Fearless concurrency — свойство Rust, при котором гонки данных отлавливаются на этапе компиляции тем же механизмом владения и заимствования, что и обычные ошибки с памятью.
Вопрос: «Что такое fearless concurrency и почему в Rust нельзя написать гонку данных?»
Что на самом деле проверяет интервьюер
Хочет услышать, что вы понимаете механизм, а не лозунг с сайта. Слабый ответ: «Rust безопасный, он не даёт делать гонки». Сильный ответ: гонки данных ловит не какая-то отдельная подсистема, а тот же borrow checker. Data race — это три условия одновременно: два и более потока обращаются к одной ячейке, хотя бы один пишет, и нет синхронизации. Правило Rust «либо одна &mut T, либо сколько угодно &T» ломает второе условие ещё до запуска. Никакой рантайм-детектор не нужен.
Важная оговорка, которую senior обязан сделать сам: Rust гарантирует отсутствие data race, но не отсутствие race condition в логике и не отсутствие deadlock. Программу, которая читает баланс, засыпает и записывает устаревшее значение, компилятор напишет с удовольствием.
Базовый пример: spawn и join
use std::thread;
use std::time::Duration;
fn main() {
let handle = thread::spawn(|| {
for i in 1..4 {
println!("поток: {i}");
thread::sleep(Duration::from_millis(10));
}
});
for i in 1..3 {
println!("main: {i}");
thread::sleep(Duration::from_millis(10));
}
handle.join().unwrap();
println!("готово");
}
thread::spawn возвращает JoinHandle<T>, а join() блокирует текущий поток до завершения дочернего и отдаёт Result — Err, если дочерний поток запаниковал.
Вывод:
main: 1 поток: 1 main: 2 поток: 2 поток: 3 готово
Порядок первых пяти строк недетерминирован: планировщик может чередовать их как угодно. Гарантировано только то, что готово печатается последним — за это отвечает join.
Что будет, если не сделать join
use std::thread;
use std::time::Duration;
fn main() {
thread::spawn(|| {
thread::sleep(Duration::from_millis(50));
println!("это может не напечататься");
});
println!("main завершается");
}
Вывод:
main завершается
Когда завершается main, процесс завершается вместе со всеми потоками — их никто не ждёт и деструкторы в них не отработают. Это частый вопрос на собеседовании: «кто кого переживёт». Ответ: рабочие потоки не переживают main.
Вопрос: «Зачем нужен move в замыкании потока?»
Что на самом деле проверяет интервьюер
Может ли кандидат объяснить связь между лайфтаймами и потоками. thread::spawn требует замыкание с границей 'static: поток может жить сколько угодно долго, поэтому он не имеет права держать ссылку на что-то, что живёт короче. Компилятор не знает, что вы «сразу же сделаете join» — сигнатура этого не выражает.
Код, который не компилируется
use std::thread;
fn main() {
let data = vec![1, 2, 3];
let handle = thread::spawn(|| {
println!("{:?}", data);
});
handle.join().unwrap();
}
Ошибка компиляции:
error[E0373]: closure may outlive the current function, but it borrows `data`, which is owned by the current function
--> src/main.rs:6:32
|
6 | let handle = thread::spawn(|| {
| ^^ may outlive borrowed value `data`
7 | println!("{:?}", data);
| ---- `data` is borrowed here
|
note: function requires argument type to outlive `'static`
--> src/main.rs:6:18
help: to force the closure to take ownership of `data` (and any other referenced variables), use the `move` keyword
|
6 | let handle = thread::spawn(move || {
| ++++
Замыкание по умолчанию захватывает data по ссылке — этого достаточно для println!. Ключевое слово move меняет способ захвата: значение перемещается внутрь замыкания и уезжает в другой поток.
После move переменная снаружи недоступна
use std::thread;
fn main() {
let data = vec![1, 2, 3];
let handle = thread::spawn(move || {
println!("в потоке: {:?}", data);
});
// println!("{:?}", data); // error[E0382]: borrow of moved value: `data`
handle.join().unwrap();
}
Раскомментируйте строку — получите error[E0382]: borrow of moved value: `data`. Это не особенность потоков, а обычное перемещение владения: move ничего не «копирует в поток», он просто переносит владение. Для типов, реализующих Copy (например i32), move копирует, и оригинал остаётся доступным — на этом кандидаты часто спотыкаются.
Вопрос: «Что означают трейты Send и Sync?»
Что на самом деле проверяет интервьюер
Точность формулировок. Правильно так:
- Send — значение типа
Tможно переместить в другой поток. - Sync — на значение типа
Tможно безопасно держать&Tиз нескольких потоков одновременно. Формально:T: Syncтогда и только тогда, когда&T: Send.
Оба — маркерные auto-трейты: компилятор выводит их автоматически для типа, все поля которого их реализуют. Ручная реализация возможна только через unsafe impl. Исключения, которые обязательно спросят: Rc<T> не Send и не Sync (счётчик ссылок неатомарный), RefCell<T> — Send, но не Sync (флаг заимствования проверяется в рантайме и неатомарен), сырые указатели *const T и *mut T — ни то ни другое. MutexGuard не Send, потому что во многих ОС лок обязан освобождать тот же поток, что его взял.
Ошибка, которую видят все новички в многопоточности
use std::rc::Rc;
use std::thread;
fn main() {
let counter = Rc::new(1);
let cloned = Rc::clone(&counter);
thread::spawn(move || {
println!("{}", cloned);
})
.join()
.unwrap();
}
Ошибка компиляции:
error[E0277]: `Rc<i32>` cannot be sent between threads safely
--> src/main.rs:8:19
|
8 | thread::spawn(move || {
| ------------- ^------
| | |
| | `Rc<i32>` cannot be sent between threads safely
| required by a bound introduced by this call
|
= help: within this closure, the trait `Send` is not implemented for `Rc<i32>`
note: required because it appears within the type of the closure
note: required by a bound in `spawn`
Лечится заменой Rc на Arc — об этом следующий урок. Обратите внимание на форму ошибки: компилятор не говорит «вы сделали гонку», он говорит «этот тип не помечен как безопасный для передачи». Вся многопоточная безопасность выражена в системе типов.
Типичные ошибки кандидатов
- Говорить «Rust гарантирует отсутствие гонок» без уточнения, что речь только про data race: deadlock и логические race condition остаются на совести разработчика.
- Путать
SendиSyncместами. Мнемоника: Send — отправить значение, Sync — разделить ссылку. - Считать, что
moveнужен «чтобы поток скопировал данные». Он переносит владение, а не копирует. - Думать, что
Send/Syncнадо реализовывать руками. Это auto-трейты; ручнойunsafe impl— редкий случай для обёрток над сырыми указателями. - Забывать, что без
join()завершениеmainубивает потоки и деструкторы в них не отработают. - Утверждать, что
Rc«просто медленнее в потоках». Он вообще не компилируется в этом контексте.
Как ответить кратко
«Fearless concurrency означает, что гонки данных ловит тот же borrow checker: правило одной мутабельной ссылки убирает саму возможность неконтролируемой одновременной записи, отдельного рантайм-механизма нет. thread::spawn требует 'static-замыкание, поэтому захват по ссылке даёт E0373 и нужен move, который переносит владение внутрь потока. Что можно передавать, описывают два auto-трейта: Send — тип можно переместить в другой поток, Sync — на тип можно иметь &T из нескольких потоков, то есть &T: Send. Почти всё реализует их автоматически; исключения — Rc, RefCell и сырые указатели. При этом от deadlock и логических гонок Rust не защищает.»