Потоки, передача владения, 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() блокирует текущий поток до завершения дочернего и отдаёт ResultErr, если дочерний поток запаниковал.

Вывод:

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 не защищает.»

Проверьте себя
1. Почему компилятор требует move в замыкании, переданном в thread::spawn?
AПотому что thread::spawn копирует данные, а move включает копирование
BПотому что spawn требует замыкание с границей 'static, а захват по ссылке этого не гарантирует
CПотому что без move замыкание нельзя вызвать больше одного раза
DПотому что move делает тип автоматически Send
2. Что означает, что тип T реализует Sync?
AЗначение T можно переместить в другой поток
BT защищён внутренним мьютексом
CНа T можно безопасно держать &T из нескольких потоков, то есть &T реализует Send
DВсе методы T выполняются атомарно
3. Почему значение типа Rc нельзя отправить в другой поток?
ARc всегда живёт на стеке и не может пересечь границу потока
BСчётчик ссылок Rc неатомарный, поэтому Rc не реализует Send
CRc не реализует Clone, а spawn требует Clone
DRc не может хранить примитивные типы