сеньорчикОткрыть в Telegram
← вся теориятеория к собесу · Многопоточность Rust

Потоки в Rust: spawn, join и scope

Потоки

С потоков начинается вся многопоточная секция собеса. Спрашивают, почему spawn требует move, что возвращает join, чем хорош scope и почему поток на задачу - плохая идея под нагрузкой.

Стержень: поток может пережить функцию, которая его запустила, поэтому замыкание обязано быть самодостаточным - либо владеть данными, либо жить внутри области с гарантией завершения.

// Формулировки: «зачем move в spawn?», «что вернёт join?», «что будет, если main завершится раньше потока?»

Почему нужен move

thread::spawn принимает замыкание с ограничением 'static: у потока своя жизнь, и он может пережить вызывающую функцию. Заимствовать её локальные переменные нельзя - ссылка стала бы висячей. Без move компилятор скажет: closure may outlive the current function (E0373).

move передаёт владение внутрь, и вопрос снимается. Если владение отдавать не хочется, есть thread::scope: область не завершится, пока живы её потоки, поэтому им можно передавать обычные ссылки на локальные данные. Пула потоков там нет - каждый spawn по-прежнему создаёт системный поток.

// Проверено: внутри scope доступ к вектору по ссылке работает, а после выхода из области вектор жив и им можно пользоваться дальше.

let v = vec![1, 2, 3];
thread::scope(|s| {
    s.spawn(|| println!("{v:?}"));
});
println!("{v:?}"); // жив
'static
требование: замыкание не заимствует чужие локальные данные
scoped threads
потоки, гарантированно завершающиеся внутри области

join, паника и завершение

join() ждёт поток и возвращает Result: Ok со значением замыкания либо Err с упакованной причиной паники. Так падение отдельного потока становится значением, а не катастрофой: главный поток решает, повторить работу, залогировать или упасть следом.

Если handle отбросить, поток продолжит работу, но выход из main завершает процесс со всеми потоками разом, без деструкторов и сброса буферов. Отсюда правило: нужен результат - сохраняй handle и делай join; поток фоновый это должно быть осознанным решением, а не забытым вызовом.

// Системный поток стоит памяти под стек и работы планировщика. На тысячах мелких задач берут пул (rayon) для счётной работы или асинхронный рантайм для ожидания ввода-вывода.

JoinHandle
дескриптор потока: join отдаёт результат или причину паники
пул потоков
фиксированный набор рабочих потоков под множество задач

Как отвечать: «Почему thread::spawn требует move?»

Потому что поток живёт независимо и может пережить функцию, которая его запустила. Замыкание обязано удовлетворять 'static, то есть не ссылаться на локальные переменные вызывающего, иначе ссылка стала бы висячей. move передаёт владение внутрь замыкания и снимает вопрос. Если отдавать владение неудобно, есть thread::scope: область не завершается, пока живы порождённые в ней потоки, поэтому компилятор разрешает передавать туда обычные ссылки. Это удобно для параллельной обработки куска данных без клонирования.

Ты объясняешь причину через время жизни, а не через «так надо», и знаешь про scope - его добавили сравнительно недавно, и упоминание сразу выдаёт актуальные знания.

На чём валятся

  • Забывают move и не могут расшифровать ошибку про переживший функцию замыкание.
  • Считают, что join нужен только для ожидания, и не знают, что он возвращает результат.
  • Уверены, что процесс дождётся фоновых потоков после выхода из main.
  • Не знают про thread::scope и клонируют данные ради каждого потока.
  • Плодят поток на задачу вместо пула и упираются в память и переключения контекста.

Проверьте себя

Пять вопросов из банка по этой подтеме. Всего их 12, остальные разбираются в тренажёре.

  1. #rs_threads1 / 5
    Что возвращает join() у JoinHandle?
    A)Result: значение замыкания или Err, если поток паниковал
    B)Ничего: join только ждёт завершения потока
    C)Option: None, если поток ещё выполняется
    D)Значение, возвращённое замыканием потока, напрямую
    показать ответ и разбор
    +A)Result: значение замыкания или Err, если поток паниковал

    // разбор: join блокирует вызывающий поток до завершения дочернего и отдаёт его результат: Ok(значение) при нормальном выходе, Err(Box<dyn Any>) с причиной паники — при падении. Так паника не переходит границу потока, а становится значением, и вызывающий сам решает, падать ли следом.

  2. #rs_threads2 / 5
    Что даёт thread::scope по сравнению с обычным spawn?
    A)Потоки автоматически перезапускаются при панике внутри области
    B)Потоки заимствуют локальные данные: область ждёт их конца
    C)Потоки получают доступ к данным без Mutex и синхронизации
    D)Потоки создаются быстрее: область переиспользует системные потоки
    показать ответ и разбор
    +B)Потоки заимствуют локальные данные: область ждёт их конца

    // разбор: scope не даёт области завершиться, пока живы порождённые в ней потоки, поэтому требование 'static снимается — можно передавать &data и &mut на непересекающиеся куски. Пула потоков тут нет: каждый spawn по-прежнему создаёт системный поток, а правила заимствования продолжают действовать.

  3. #rs_threads3 / 5
    Задача — обработать 100 000 мелких запросов. Почему не поток на запрос?
    A)Операционная система разрешает не больше 1024 потоков на процесс
    B)Потоки в Rust требуют явного вызова join, иначе программа зависнет
    C)thread::spawn в Rust синхронный: потоки создаются по очереди
    D)Поток стоит памяти под стек и переключений контекста — нужен пул
    показать ответ и разбор
    +D)Поток стоит памяти под стек и переключений контекста — нужен пул

    // разбор: Системный поток — это мегабайты виртуальной памяти под стек и работа планировщика на каждом переключении. На сотне тысяч коротких задач накладные расходы съедают всю пользу. Отсюда два стандартных пути: пул потоков (rayon, свои воркеры) для счётных задач и асинхронный рантайм для задач с ожиданием ввода-вывода.

  4. #rs_threads4 / 5
    Главный поток завершил main, пока рабочий поток ещё считает. Что произойдёт?
    A)Процесс дождётся завершения всех порождённых потоков
    B)Рабочий поток продолжит работу как фоновый процесс
    C)Процесс завершится, недоделанная работа потока пропадёт
    D)Компилятор потребует join для каждого созданного потока
    показать ответ и разбор
    +C)Процесс завершится, недоделанная работа потока пропадёт

    // разбор: Выход из main завершает процесс со всеми потоками — деструкторы в них не отработают, буферы не сбросятся. Поэтому если результат нужен, handle сохраняют и вызывают join; если поток фоновый и живёт до конца программы, это осознанное решение, а не забытый join.

  5. #rs_threads5 / 5
    Что делает thread::spawn?
    A)Ставит задачу в очередь пула потоков, который язык создаёт при старте программы
    B)Создаёт поток операционной системы и запускает в нём переданное замыкание
    C)Создаёт легковесную задачу рантайма, которую планировщик раскладывает по ядрам
    D)Клонирует текущий процесс, отдавая копии общую память для совместной работы
    показать ответ и разбор
    +B)Создаёт поток операционной системы и запускает в нём переданное замыкание

    // разбор: Это тонкая обёртка над потоком ОС: свой стек в мегабайты, планирование ядром, вытесняющая многозадачность. Никакого встроенного пула и легковесных задач в стандартной библиотеке нет — за ними идут в rayon для счётной работы и в tokio для асинхронной. Возвращается JoinHandle, через который забирают результат.

дальше

Теорию прочитали. Навык ставится повторением

В Сеньорчике эта подтема идёт в ежедневных сессиях: движок возвращает её, пока ответы не станут уверенными, и ведёт прогресс отдельно по каждой подтеме. Теория внутри тоже бесплатна, лимит только на количество вопросов в день.