Вопросы на собеседовании по Rust
Эти вопросы снова и снова звучат на собеседованиях по Rust — от позиций для начинающих до senior. Каждый ответ достаточно короткий, чтобы произнести его вслух, и ведёт на урок, где тема разобрана подробно.
Не заучивай ответы слово в слово. Интервьюеры это замечают и обычно задают уточняющий вопрос, чтобы проверить, понимаешь ли ты суть. Используй эту страницу, чтобы проверить свои знания, а всё, в чём не уверен, подтяни по ссылке на урок. Если можешь объяснить ответ своими словами и написать крошечный пример — ты готов.
Вопрос: Что такое Cargo?
Заголовок раздела «Вопрос: Что такое Cargo?»Cargo — официальная система сборки и менеджер пакетов Rust. Он ставится вместе с Rust. Одним инструментом можно:
- создать проект (
cargo new), - собрать и запустить его (
cargo build,cargo run), - запустить тесты (
cargo test), - добавить библиотеки, которые называются крейтами (crates), с crates.io (
cargo add serde).
Настройки и зависимости проекта лежат в Cargo.toml. Точные версии фиксируются в Cargo.lock, поэтому сборки воспроизводимы. Хороший дополнительный довод: в других языках для этого часто нужно несколько инструментов, а в Rust все пользуются Cargo, поэтому любой проект выглядит знакомо.
Подробнее: Cargo и крейты, Начало работы.
Вопрос: Чем затенение отличается от mut?
Заголовок раздела «Вопрос: Чем затенение отличается от mut?»mut позволяет менять значение той же самой переменной. Тип при этом должен остаться прежним.
Затенение (shadowing) — это повторный let с тем же именем. Он создаёт новую переменную, которая прячет старую. У новой может быть даже другой тип.
fn main() { let spaces = " "; let spaces = spaces.len(); // затенение: новая переменная, теперь число println!("{spaces}");
let mut count = 1; count += 1; // mut: та же переменная получает новое значение println!("{count}");}Вывод:
32Затенение удобно, когда значение превращается шаг за шагом (например, текст разбирается в число), и не хочется придумывать имена вроде input_str и input_num. А итоговая переменная всё равно остаётся неизменяемой.
Подробнее: Переменные.
Вопрос: Чем макрос отличается от функции?
Заголовок раздела «Вопрос: Чем макрос отличается от функции?»Функция вызывается во время выполнения с фиксированным числом аргументов определённых типов. Макрос работает во время компиляции: он принимает код и пишет ещё код до того, как компилятор начнёт проверку. Макросы легко узнать по !: println!, vec!, format!.
Макросы умеют то, чего не умеют функции: например, принимать переменное число аргументов (println!("{} {}", a, b)) или проверять строку формата при компиляции. К тому же макросы Rust работают со структурой кода, а не с сырым текстом, поэтому классических ошибок макросов C у них нет:
macro_rules! square { ($x:expr) => { $x * $x };}
fn main() { // В C текстовый макрос дал бы 2 + 1 * 2 + 1 = 5. println!("{}", square!(2 + 1));}Вывод:
9Честная цена: макросы труднее читать, писать и отлаживать. Если задачу решает функция, бери функцию.
Подробнее: Макросы.
Вопрос: Что значит «абстракции с нулевой стоимостью»?
Заголовок раздела «Вопрос: Что значит «абстракции с нулевой стоимостью»?»Это значит, что высокоуровневые возможности не должны делать программу медленнее, чем низкоуровневый код, который ты написал бы вручную. «Ты не платишь за то, чем не пользуешься, а то, чем пользуешься, ты не написал бы лучше сам».
Например, цепочка итераторов вроде .iter().filter(...).map(...).sum() обычно компилируется в тот же машинный код, что и написанный вручную цикл. Другой пример — обобщения: компилятор делает отдельную специализированную копию для каждого используемого типа (это называется мономорфизацией), поэтому во время выполнения ничего не ищется.
Будь честен насчёт «стоимости»: нулевая она именно во время выполнения. Платишь ты временем компиляции, а обобщения могут увеличить размер исполняемого файла.
Подробнее: Итераторы, Обобщения.
Вопрос: Итераторы в Rust ленивые?
Заголовок раздела «Вопрос: Итераторы в Rust ленивые?»Да. Методы вроде .map() и .filter() сами по себе ничего не делают. Они строят описание работы. Ничего не происходит, пока что-нибудь не потребит итератор: .collect(), .sum() или цикл for.
fn main() { let numbers = vec![1, 2, 3]; let doubled = numbers.iter().map(|n| { println!("удваиваю {n}"); n * 2 });
println!("Пока ничего не выполнилось."); let result: Vec<i32> = doubled.collect(); println!("{:?}", result);}Вывод:
Пока ничего не выполнилось.удваиваю 1удваиваю 2удваиваю 3[2, 4, 6]Поэтому же компилятор предупреждает, если написать numbers.iter().map(...); и не использовать результат: такой код ничего бы не сделал.
Подробнее: Итераторы.
Владение и заимствование
Заголовок раздела «Владение и заимствование»Вопрос: Какие правила владения существуют?
Заголовок раздела «Вопрос: Какие правила владения существуют?»Их три:
- У каждого значения в Rust есть один владелец (переменная).
- В каждый момент владелец может быть только один.
- Когда владелец выходит из области видимости, значение удаляется (drop), и его память освобождается.
Эти правила позволяют Rust освобождать память ровно в нужный момент — без сборщика мусора и без ручного вызова free. Компилятор их проверяет, поэтому ошибки вроде «использования после освобождения» (use after free) или «двойного освобождения» (double free) в безопасном коде невозможны.
Подробнее: Владение, Область видимости.
Вопрос: Чем отличаются перемещение, копирование и клонирование?
Заголовок раздела «Вопрос: Чем отличаются перемещение, копирование и клонирование?»- Перемещение (move): присваивание или передача значения переносит владение. Старой переменной пользоваться уже нельзя. Так по умолчанию работают типы вроде
StringиVec. Копируются только маленький указатель, длина и ёмкость. - Копирование (copy): у маленьких простых типов вроде
i32,f64,boolиcharприсваивание делает дешёвую побитовую копию, и обе переменные остаются рабочими. - Клонирование (clone):
.clone()— явное глубокое копирование. ДляStringоно копирует весь текст в новую память. Это может быть дорого, поэтому Rust заставляет писать его явно.
fn main() { let x = 5; let y = x; // Copy: x по-прежнему можно использовать println!("{x} {y}");
let a = String::from("привет"); let b = a.clone(); // Clone: полная копия текста let c = a; // Move: после этой строки `a` использовать нельзя println!("{b} {c}");}Вывод:
5 5привет приветПодробнее: Владение.
Вопрос: Что такое трейт Copy и у каких типов он есть?
Заголовок раздела «Вопрос: Что такое трейт Copy и у каких типов он есть?»Copy — трейт-маркер, который говорит: «этот тип можно продублировать, просто скопировав его биты». Типы с Copy при присваивании копируются, а не перемещаются.
Copy есть у целых и дробных чисел, bool, char, общих ссылок (&T), а также у кортежей и массивов, состоящих только из Copy-типов. У String, Vec и Box его нет: они владеют памятью где-то ещё, и копирование указателя дало бы двух владельцев.
Для своих типов его можно получить через derive, но только если каждое поле — Copy:
#[derive(Clone, Copy)]struct User { name: String, age: u32,}error[E0204]: the trait `Copy` cannot be implemented for this type --> src/main.rs:2:8 |1 | #[derive(Clone, Copy)] | ---- in this derive macro expansion2 | struct User { | ^^^^3 | name: String, | ------------ this field does not implement `Copy`Компилятор говорит, что Copy нельзя реализовать для этого типа, потому что поле name не реализует Copy. Copy всегда требует ещё и Clone. Кроме того, тип, реализующий Drop, не может быть Copy.
Подробнее: Распространённые трейты, Владение.
Вопрос: Чем String отличается от &str?
Заголовок раздела «Вопрос: Чем String отличается от &str?»String— владеющий текст, который может расти и хранится в куче. Его можно менять, например черезpush_str.&str(«строковый срез») — заимствованный взгляд на какой-то текст. Это просто указатель и длина. Строковые литералы вроде"hello"имеют тип&str.
Хорошее правило: функции, которые только читают текст, должны принимать &str. Тогда они подходят для обоих типов, потому что &String автоматически превращается в &str.
fn greet(name: &str) { println!("Привет, {name}!");}
fn main() { let owned: String = String::from("Аня"); let literal: &str = "Борис";
greet(&owned); // &String подходит там, где ждут &str greet(literal);
let mut full = owned; full.push_str(" Смирнова"); // расти может только String greet(&full);}Вывод:
Привет, Аня!Привет, Борис!Привет, Аня Смирнова!Вопрос: Какие правила заимствования существуют?
Заголовок раздела «Вопрос: Какие правила заимствования существуют?»В каждый момент для конкретного значения у тебя может быть либо:
- сколько угодно общих ссылок (
&T), либо - ровно одна изменяемая ссылка (
&mut T),
но не то и другое одновременно. И каждая ссылка всегда должна указывать на корректные данные.
Зачем? Если бы кто-то мог менять данные, пока кто-то другой их читает, появлялись бы ошибки вроде переезда вектора в другую память, пока ты держишь указатель внутрь него. В потоках это и есть гонка данных (data race). Rust предотвращает её ещё при компиляции. Начиная с редакции 2018, компилятор умно определяет, когда заканчивается заимствование (это называется нелексическими временами жизни, non-lexical lifetimes): заимствование длится только до последнего использования, а не до конца блока.
Подробнее: Заимствование.
Вопрос: Что такое времена жизни?
Заголовок раздела «Вопрос: Что такое времена жизни?»Время жизни (lifetime) — это то, как компилятор называет «сколько времени ссылка действительна». Оно есть у каждой ссылки. Чаще всего компилятор выводит времена жизни сам (это называется элизией времён жизни, lifetime elision), так что ты их вообще не видишь.
Писать их нужно только тогда, когда компилятор не может понять, как выходная ссылка связана со входными, — например, в функции, которая возвращает одну из двух заимствованных строк:
fn longest<'a>(a: &'a str, b: &'a str) -> &'a str { if a.len() >= b.len() { a } else { b }}
fn main() { let first = String::from("borrow checker"); let second = String::from("lifetimes"); println!("{}", longest(&first, &second));}Вывод:
borrow checkerСтроки здесь английские, потому что .len() считает байты, а не буквы. Важная мысль, которую стоит произнести на собеседовании: аннотации времён жизни не меняют того, сколько что-либо живёт. Они только описывают связи, чтобы компилятор мог проверить, что ни одна ссылка не переживёт данные, на которые указывает.
Подробнее: Времена жизни.
Типы и трейты
Заголовок раздела «Типы и трейты»Вопрос: В Rust нет null. Что вместо него?
Заголовок раздела «Вопрос: В Rust нет null. Что вместо него?»Option<T> — перечисление с двумя вариантами: Some(value) или None. Это другой тип, не T, поэтому компилятор заставляет обработать случай None, прежде чем ты сможешь использовать значение. Забыть проверку на null нельзя: код просто не скомпилируется.
fn find_user(id: u32) -> Option<&'static str> { match id { 1 => Some("Аня"), 2 => Some("Борис"), _ => None, }}
fn main() { for id in [1, 3] { match find_user(id) { Some(name) => println!("Найден: {name}"), None => println!("Нет пользователя с id {id}"), } }}Вывод:
Найден: АняНет пользователя с id 3Приятная деталь: Option<&T> и Option<Box<T>> не занимают больше памяти, чем обычный указатель. Внутри Rust использует «нулевой» набор битов для обозначения None, так что безопасность здесь ничего не стоит.
Вопрос: Что такое трейт? Это то же самое, что интерфейс?
Заголовок раздела «Вопрос: Что такое трейт? Это то же самое, что интерфейс?»Трейт задаёт набор методов, которые тип может реализовать. Он похож на интерфейс в Java, C# или Go, и с этого вполне можно начать ответ. Потом назови отличия:
- У трейтов могут быть реализации методов по умолчанию.
- Трейт можно реализовать для типа после того, как тип написан, — даже для чужих типов, например реализовать свой трейт для
i32. (По «правилу сироты», orphan rule, твоим должен быть либо трейт, либо тип.) - Трейты используются как ограничения обобщений (
T: Display), которые разрешаются при компиляции, и как трейт-объекты (dyn Display) во время выполнения. - В Rust нет наследования классов. Его заменяют трейты плюс композиция.
Подробнее: Трейты, Распространённые трейты.
Вопрос: Чем обобщения отличаются от трейт-объектов?
Заголовок раздела «Вопрос: Чем обобщения отличаются от трейт-объектов?»И то и другое позволяет одной функции работать со многими типами. Разница в том, когда определяется тип:
- Обобщения (
fn f<T: Speak>(x: &T)) используют статическую диспетчеризацию. Компилятор делает отдельную копию функции для каждого типа. Вызовы такие же быстрые, как обычные вызовы функций, но программа может вырасти в размере. - Трейт-объекты (
&dyn Speak,Box<dyn Speak>) используют динамическую диспетчеризацию. Копия функции одна, а нужный метод ищется во время выполнения через таблицу («vtable»). Это чуть-чуть медленнее, зато позволяет смешивать разные типы в одной коллекции.
trait Speak { fn speak(&self) -> String;}
struct Dog;struct Cat;
impl Speak for Dog { fn speak(&self) -> String { String::from("Гав") }}
impl Speak for Cat { fn speak(&self) -> String { String::from("Мяу") }}
fn speak_static<T: Speak>(animal: &T) -> String { animal.speak()}
fn speak_dynamic(animal: &dyn Speak) -> String { animal.speak()}
fn main() { println!("{}", speak_static(&Dog)); println!("{}", speak_dynamic(&Cat));
// Смешать типы в одном Vec можно только с трейт-объектами: let animals: Vec<Box<dyn Speak>> = vec![Box::new(Dog), Box::new(Cat)]; for animal in &animals { println!("{}", animal.speak()); }}Вывод:
ГавМяуГавМяуПростое правило: начинай с обобщений. Трейт-объекты бери, когда нужна коллекция разных типов или хочется сократить время компиляции и размер исполняемого файла.
Подробнее: Обобщения, Трейт-объекты.
Вопрос: Чем Box<dyn Trait> отличается от impl Trait?
Заголовок раздела «Вопрос: Чем Box<dyn Trait> отличается от impl Trait?»Оба способа позволяют сказать «какой-то тип, реализующий этот трейт», не называя его. Разница в том, сколько конкретных типов допускается:
impl Traitозначает один конкретный тип, который компилятор знает, но не заставляет тебя его писать. Это статическая диспетчеризация: ни выделения памяти в куче, ни vtable — так же быстро, как если бы ты назвал тип сам. В позиции аргумента (fn f(x: impl Display)) это просто короткая запись обобщения. В позиции возвращаемого значения так возвращают вещи с длинными или безымянными типами, например замыкания и цепочки итераторов.Box<dyn Trait>— трейт-объект в куче. Настоящий тип может быть каждый раз другим и определяется во время выполнения, а метод находится через vtable. Это стоит одного выделения памяти и косвенного вызова, зато только так можно вернуть один из нескольких типов или сложить их в одинVec.
use std::fmt::Display;
// impl Trait: один скрытый тип, известный при компиляции.fn evens(limit: u32) -> impl Iterator<Item = u32> { (0..limit).filter(|n| n % 2 == 0)}
// Box<dyn Trait>: настоящий тип может меняться во время выполнения.fn label(as_number: bool) -> Box<dyn Display> { if as_number { Box::new(42) } else { Box::new("сорок два") }}
fn main() { let v: Vec<u32> = evens(10).collect(); println!("{v:?}"); println!("{}", label(true)); println!("{}", label(false));}Вывод:
[0, 2, 4, 6, 8]42сорок дваХорошая деталь для собеседования: если переделать label, чтобы она возвращала impl Display, и убрать коробки, код не скомпилируется. Ветка if возвращает i32, ветка else — &str, а impl Trait обязан быть одним-единственным типом. Простое правило: по умолчанию используй impl Trait, а на Box<dyn Trait> переходи, когда действительно нужно выбирать между типами во время выполнения.
Подробнее: Трейт-объекты, Обобщения.
Вопрос: Почему match должен быть исчерпывающим?
Заголовок раздела «Вопрос: Почему match должен быть исчерпывающим?»match обязан обработать все возможные значения, иначе код не скомпилируется. Поэтому, когда ты добавляешь в перечисление новый вариант, компилятор показывает каждое место, которое нужно обновить. В других языках забытый case в switch молча ничего не делает.
Можно использовать _ как ветку «для всего остального», но осторожно: она прячет и новые варианты, которые ты добавишь позже, и ты теряешь эту страховку.
Подробнее: Match, Сопоставление с образцом, Перечисления.
Обработка ошибок
Заголовок раздела «Обработка ошибок»Вопрос: Как Rust обрабатывает ошибки без исключений?
Заголовок раздела «Вопрос: Как Rust обрабатывает ошибки без исключений?»Ошибки, после которых можно продолжить работу, — это обычные значения типа Result<T, E>: либо Ok(value), либо Err(error). Функция, которая может не сработать, говорит об этом в своём возвращаемом типе, и вызывающий код обязан с этим разобраться. Ничто не улетает незаметно вверх по стеку, как исключение.
Плюсы: по сигнатуре функции видно, что она может завершиться ошибкой, а компилятор предупреждает, если ты проигнорировал Result. Цена — чуть больше кода, но оператор ? убирает почти весь лишний.
Подробнее: Result.
Вопрос: Когда использовать panic!, а когда возвращать Result?
Заголовок раздела «Вопрос: Когда использовать panic!, а когда возвращать Result?»- Возвращай
Result, когда неудача ожидаема и вызывающий код вполне может с ней справиться: нет файла, неверный ввод пользователя, тайм-аут сети. - Паникуй, когда это ошибка в программе (баг): программа попала в состояние, которое «должно быть невозможным», и продолжать было бы неправильно. Именно поэтому паникует обращение по индексу за концом вектора.
Паника нормальна и в тестах, примерах и быстрых прототипах. В библиотеке не паникуй из-за входных данных, которые контролирует вызывающий: верни ошибку, и пусть он решает.
Вопрос: Чем отличаются unwrap, expect и ??
Заголовок раздела «Вопрос: Чем отличаются unwrap, expect и ??»unwrap()отдаёт значение внутриOk/Someили паникует с общим сообщением.expect("msg")делает то же самое, но паникует с твоим сообщением. Используй его, чтобы объяснить, почему ты уверен, что здесь ошибки быть не может.?не паникует. ПриErr/Noneон возвращает ошибку из текущей функции, чтобы с ней разобрался вызывающий код. Сама функция при этом должна возвращатьResultилиOption.
use std::num::ParseIntError;
fn double(text: &str) -> Result<i32, ParseIntError> { let n: i32 = text.parse()?; // при ошибке вернуть её вызывающему Ok(n * 2)}
fn main() { println!("{:?}", double("21")); println!("{:?}", double("abc"));
let n: i32 = "7".parse().expect("число, записанное прямо в коде, всегда должно разбираться"); println!("{n}");}Вывод:
Ok(42)Err(ParseIntError { kind: InvalidDigit })7В рабочем коде предпочитай ?. expect используй, когда неудача означала бы баг, а unwrap оставь для тестов и быстрых экспериментов.
Вопрос: Как создать свои типы ошибок?
Заголовок раздела «Вопрос: Как создать свои типы ошибок?»Обычно через перечисление, в котором перечислены все способы, которыми твой код может не сработать, плюс реализации std::fmt::Display и std::error::Error. Если реализовать From для других типов ошибок, ? будет преобразовывать их автоматически.
На практике многие проекты используют вспомогательные крейты: thiserror (на английском) — чтобы объявлять типы ошибок в библиотеках, и anyhow (на английском) — для приложений, которым нужно просто передавать ошибки наверх с контекстом. Для быстрых программ подойдёт Box<dyn Error>, в который помещается любая ошибка.
Подробнее: Свои типы ошибок.
Вопрос: Когда брать anyhow, а когда thiserror?
Заголовок раздела «Вопрос: Когда брать anyhow, а когда thiserror?»Они решают две половины одной задачи, поэтому честный ответ — «часто оба»:
thiserror— для библиотек или любого кода, где вызывающему нужно реагировать на конкретные ошибки. Ты пишешь своё перечисление ошибок, а#[derive(Error)]заполняет шаблонный код дляDisplayиError. Вызывающий получает настоящий тип, по которому можно сделатьmatch, напримерConfigError::MissingField.anyhow— для приложений, где большинство ошибок нужно просто сообщить, а не обрабатывать по одной. Каждая функция возвращаетanyhow::Result<T>,?принимает почти любой тип ошибки, а.context("while reading config")добавляет сверху понятное человеку пояснение. Цена: по такой ошибке вызывающему уже не так просто сделатьmatch.
Частая схема: thiserror внутри крейтов-библиотек и anyhow в main и остальной части исполняемой программы. Никакой магии ни в одном из крейтов нет. Оба лишь убирают шаблонный код поверх стандартного трейта std::error::Error, так что всё то же самое всегда можно написать вручную.
Подробнее: anyhow и thiserror, Свои типы ошибок.
Память и умные указатели
Заголовок раздела «Память и умные указатели»Вопрос: Есть ли в Rust сборщик мусора?
Заголовок раздела «Вопрос: Есть ли в Rust сборщик мусора?»Нет. Память освобождается, когда её владелец выходит из области видимости, — в момент, который компилятор знает заранее (это называется RAII, а метод очистки — Drop). Так получается предсказуемая производительность без пауз на сборку мусора.
Когда действительно нужно совместное владение, ты явно включаешь подсчёт ссылок (Rc или Arc), который освобождает значение, когда уходит последний владелец.
Подробнее: Владение, Умные указатели.
Вопрос: Когда использовать Box, Rc, Arc и RefCell?
Заголовок раздела «Вопрос: Когда использовать Box, Rc, Arc и RefCell?»Box<T>: один владелец, значение в куче. Подходит для рекурсивных типов (например, дерева или связного списка), больших значений и трейт-объектов (Box<dyn Trait>).Rc<T>: несколько владельцев в одном потоке. Считает ссылки и освобождает значение, когда счётчик доходит до нуля. Данные доступны только для чтения.Arc<T>: какRc, но счётчик атомарный, поэтому им безопасно делиться между потоками. Чуть медленнееRc.RefCell<T>: позволяет менять данные через общую ссылку («внутренняя изменяемость», interior mutability). Правила заимствования проверяются во время выполнения, а не при компиляции, и их нарушение вызывает панику.
Их можно сочетать. Rc<RefCell<T>> — это «общее и изменяемое, в одном потоке». Arc<Mutex<T>> — потокобезопасная версия.
use std::cell::RefCell;use std::rc::Rc;
fn main() { let shared = Rc::new(RefCell::new(vec![1, 2])); let other_owner = Rc::clone(&shared);
other_owner.borrow_mut().push(3);
println!("{:?}", shared.borrow()); println!("владельцев: {}", Rc::strong_count(&shared));}Вывод:
[1, 2, 3]владельцев: 2Подробнее: Умные указатели.
Вопрос: Можно ли допустить утечку памяти в безопасном Rust?
Заголовок раздела «Вопрос: Можно ли допустить утечку памяти в безопасном Rust?»Да, и честно об этом сказать — правильно. Rust гарантирует безопасность памяти (никакого использования после освобождения, никаких висячих указателей, никаких гонок данных), но утечка памяти небезопасной не считается. Два частых способа:
- Цикл из
Rc: два значения, которые указывают друг на друга черезRc, никогда не доведут счётчик до нуля. Исправление — сделать одно из направлений ссылкойWeak. - Функции вроде
Box::leakиstd::mem::forget, которые допускают утечку намеренно.
В повседневном коде утечки редки, потому что владение освобождает почти всё автоматически.
Подробнее: Умные указатели.
Вопрос: Что такое unsafe и когда он используется?
Заголовок раздела «Вопрос: Что такое unsafe и когда он используется?»unsafe отмечает блок (или функцию), где можно делать пять дополнительных вещей, которые компилятор не может проверить: например, разыменовывать сырые указатели, вызывать unsafe-функции (в том числе код на C через FFI) или обращаться к изменяемым статическим переменным. Внутри такого блока ты обещаешь, что правила соблюдены.
Важно: unsafe не отключает анализатор заимствований и другие проверки. Он только открывает эти несколько дополнительных операций.
Его используют для работы с библиотеками на C, для низкоуровневых структур данных и чтобы выжать производительность там, где компилятор не может доказать безопасность. Сама стандартная библиотека использует unsafe внутри безопасных API вроде Vec. Хорошая практика: делай блоки unsafe маленькими, оборачивай их в безопасный API и пиши комментарий // SAFETY: с объяснением, почему всё корректно. Большинству прикладного кода unsafe не нужен вовсе.
Подробнее: Небезопасный Rust.
Многопоточность и async
Заголовок раздела «Многопоточность и async»Вопрос: Что значат Send и Sync?
Заголовок раздела «Вопрос: Что значат Send и Sync?»Это трейты-маркеры, с помощью которых компилятор следит за безопасностью потоков:
Send: значение этого типа можно переместить в другой поток.Sync: значением этого типа можно делиться по ссылке между потоками (&TявляетсяSend).
Большинство типов обладают обоими, и компилятор реализует их автоматически. Знаменитое исключение — Rc: его счётчик не потокобезопасен. Попробуй отправить его в поток — и код не скомпилируется:
use std::rc::Rc;use std::thread;
fn main() { let data = Rc::new(5); let handle = thread::spawn(move || { println!("{data}"); }); handle.join().unwrap();}error[E0277]: `Rc<i32>` cannot be sent between threads safely ... = help: within `{closure@src/main.rs:6:32: 6:39}`, the trait `Send` is not implemented for `Rc<i32>`Компилятор сообщает, что Rc<i32> нельзя безопасно передать между потоками: для него не реализован Send. Замени Rc на Arc — и всё заработает. Во многом это и имеют в виду, когда говорят о «бесстрашной многопоточности» (fearless concurrency) в Rust: ошибки потокобезопасности становятся ошибками компиляции.
Подробнее: Многопоточность.
Вопрос: Как делиться изменяемыми данными между потоками?
Заголовок раздела «Вопрос: Как делиться изменяемыми данными между потоками?»Классический ответ — Arc<Mutex<T>>. Arc позволяет нескольким потокам владеть значением. Mutex следит, чтобы менять его мог только один поток за раз: ты вызываешь .lock() и получаешь страж (guard), который автоматически снимает блокировку, когда выходит из области видимости.
use std::sync::{Arc, Mutex};use std::thread;
fn main() { let counter = Arc::new(Mutex::new(0)); let mut handles = Vec::new();
for _ in 0..4 { let counter = Arc::clone(&counter); handles.push(thread::spawn(move || { for _ in 0..1000 { *counter.lock().unwrap() += 1; } })); }
for handle in handles { handle.join().unwrap(); }
println!("Итого: {}", *counter.lock().unwrap());}Вывод:
Итого: 4000Назови и альтернативу: вместо общей памяти можно пересылать сообщения между потоками через каналы (std::sync::mpsc). А для простых счётчиков атомарные типы вроде AtomicUsize обходятся без блокировки.
Подробнее: Многопоточность.
Вопрос: Чем каналы отличаются от общего состояния с Mutex?
Заголовок раздела «Вопрос: Чем каналы отличаются от общего состояния с Mutex?»Это два стиля распределения работы между потоками:
- Каналы (
std::sync::mpsc) передают сообщения. Значение перемещается в канал и выходит с другой стороны, так что в каждый момент им владеет только один поток. Они хорошо подходят для конвейеров и пулов рабочих потоков: «рабочие выдают результаты, один поток их собирает». Рассуждать о них обычно проще, потому что два потока никогда не трогают одни и те же данные. - Общее состояние (
Arc<Mutex<T>>) значит, что несколько потоков используют одни и те же данные по очереди через блокировку. Это подходит, когда многим потокам нужно читать и обновлять одну структуру, например кеш или таблицу счётчиков. Риски — взаимные блокировки (deadlock) и замедление, когда потоки постоянно ждут блокировку.
Вот счётчик из предыдущего ответа, переписанный на канал. Каждый рабочий поток считает сам, без блокировки, и отправляет свой результат:
use std::sync::mpsc;use std::thread;
fn main() { let (tx, rx) = mpsc::channel();
for _ in 0..4 { let tx = tx.clone(); thread::spawn(move || { let mut count = 0; for _ in 0..1000 { count += 1; } tx.send(count).unwrap(); }); } drop(tx); // удали исходного отправителя, иначе rx.iter() никогда не закончится
let total: i32 = rx.iter().sum(); println!("Итого: {total}");}Вывод:
Итого: 4000Строка drop(tx) — отличная деталь, чтобы упомянуть: rx.iter() ждёт, пока не исчезнет каждый отправитель, поэтому если её забыть, программа зависнет. В обоих стилях Rust проверяет потокобезопасность при компиляции. Выбор зависит от того, какой дизайн понятнее для твоей задачи, и многие программы используют оба.
Подробнее: Каналы, Многопоточность.
Вопрос: Предотвращает ли Rust все ошибки многопоточности?
Заголовок раздела «Вопрос: Предотвращает ли Rust все ошибки многопоточности?»Нет, и если ты так скажешь, это покажет настоящее понимание. Безопасный Rust предотвращает гонки данных (два потока одновременно обращаются к одной памяти, и хотя бы один пишет). Но он не предотвращает:
- Взаимные блокировки (deadlock), например когда два потока ждут блокировку, которую держит другой.
- Состояния гонки в твоей логике (race condition), например когда ты проверил значение, а действуешь уже после того, как другой поток его поменял.
Так что Rust убирает целый класс трудных ошибок, но продумывать блокировки всё равно нужно самому.
Подробнее: Многопоточность.
Вопрос: Чем async отличается от потоков?
Заголовок раздела «Вопрос: Чем async отличается от потоков?»- Потоками управляет операционная система. У каждого свой стек, и ОС переключается между ними. Они отлично подходят для работы, нагружающей процессор. Но тысячи потоков съедают много памяти.
- Асинхронные задачи гораздо легче.
async fnвозвращаетFuture, а среда выполнения (runtime, обычно Tokio, на английском) выполняет много таких future на нескольких потоках. Когда задача чего-то ждёт (сеть, таймер, файл),.awaitдаёт поработать другим задачам. Это идеально для программ, которые много ждут, например для веб-серверов с тысячами соединений.
use tokio::time::{sleep, Duration};
async fn fetch(name: &str, ms: u64) -> String { sleep(Duration::from_millis(ms)).await; // делаем вид, что ждём сеть format!("{name}: готово")}
#[tokio::main]async fn main() { // Обе работают одновременно, поэтому всё займёт около 200 мс, а не 300. let (a, b) = tokio::join!(fetch("первая", 200), fetch("вторая", 100)); println!("{a}"); println!("{b}");}Вывод:
первая: готововторая: готовоДве честные детали, которые нравятся интервьюерам: future в Rust ленивые и ничего не делают, пока их не ждут через .await, а в стандартной библиотеке нет среды выполнения, так что её выбираешь ты сам. И ещё: блокирующий код (например, долгое вычисление или std::thread::sleep) внутри асинхронной задачи блокирует остальные задачи на этом потоке.
Подробнее: Async/Await, Многопоточность.
Как отвечать на собеседовании
Заголовок раздела «Как отвечать на собеседовании»- Начни просто, потом углубляйся. Сначала дай ответ одним предложением («
Rc— указатель с подсчётом ссылок для совместного владения в одном потоке»). Потом добавляй подробности, если видишь интерес. - Объясняй «зачем». Правила Rust существуют, чтобы предотвращать конкретные ошибки. Фраза «правила заимствования предотвращают гонки данных и висячие указатели» звучит сильнее, чем простое перечисление правил.
- Используй крошечный пример. Три строчки кода — написанные или описанные словами — часто объясняют больше длинной речи.
- Честно говори о компромиссах. У Rust есть цена: долгая компиляция, порог входа, больше кода для обработки ошибок. Признать это — признак опыта, а не слабости.
- Говори «не знаю», если не знаешь, а потом рассуждай вслух. «Я не работал с
Pinнапрямую, но знаю, что он нужен для future, которые нельзя перемещать в памяти» — отличный ответ. - Держи наготове историю. Интервьюеры часто просят: «Расскажи, как анализатор заимствований тебя остановил». Вспомни один реальный пример из своего кода и что ты из него вынес.
- Тренируйся, когда пишешь код. Хорошая разминка — страница Упражнения и проекты Игра «Угадай число» и Список дел.
Проверь себя
Заголовок раздела «Проверь себя»Проверь себя
1. Какой умный указатель взять, чтобы делиться данными только для чтения между потоками?
2. Что делает оператор ?, когда встречает Err?
3. Что из этого безопасный Rust предотвращает при компиляции?
4. Меняют ли аннотации времён жизни то, сколько живёт значение?
Результат: 0 / 4
Что почитать
Заголовок раздела «Что почитать»- Язык программирования Rust (книга по Rust) (перевод на русский): официальная книга. Большинство ответов на этой странице взяты прямо из её глав о владении, трейтах, ошибках и многопоточности.
- Rust на примерах (Rust by Example) (перевод на русский): короткие запускаемые примеры — удобно освежить синтаксис прямо перед собеседованием.
- The Rust Reference (на английском): точные правила языка — на случай, если интервьюер спросит: «а когда именно это происходит?»
- The Rustonomicon (на английском): руководство по
unsafeRust с более глубоким объяснениемSend,Syncи размещения в памяти — для вопросов уровня senior. - Asynchronous Programming in Rust (на английском): как сочетаются future,
async/.awaitи среды выполнения, — официальная книга по async от проекта Rust.