Реальные вопросы с собеседований по Rust, которые повторяются из раза в раз, – собраны в одну подборку: 100 вопросов с разборами и продвинутый блок A1–A21 для staff-уровня. Это не учебник, а карта местности: где компилятор Rust обычно ловит даже опытных и какие темы стоит подтянуть перед следующим собесом.
Материал основан на репозитории Develp10/rustinterviewquiestions (MIT). В статье я выжимаю самое важное: формулировки вопросов, краткие ответы с механикой, и где обычно сыпятся. Для глубокого погружения - полный репо.
Оглавление
TL;DR: что чаще всего спрашивают на каждом грейде
Грейд |
Топ-5 тем |
Что валит чаще всего |
|---|---|---|
Middle |
Владение и move, &T vs &mut T, лайфтаймы elision, базовые трейты (Clone, Copy, From), Result/Option |
Путают move с deep copy, не понимают почему Vec не Copy, не знают про NLL |
Senior |
Send/Sync, Mutex vs RwLock, async fn внутри, Pin, |
Не знают что MutexGuard через await ломает Send, путают Pin с unsafe |
Staff |
GAT, HRTB, variance, Stacked Borrows, memory ordering, soundness кастомных коллекций |
Не объяснят почему Arc strong count – Relaxed, но финальный Acquire (см. A12), не доказывают unsafe impl Send (см. A20) |
Дальше - по разделам, с краткими формулировками и ключевыми граблями. Полные ответы с примерами кода - в репо, ссылка наверху.
Блок 1: владение, заимствование, лайфтаймы (вопросы 1-15)
Это самый частый блок на собеседовании. Если плаваете тут - дальше не пройдёте. Каждый ответ - не школьное определение, а то, что отличает кандидата на 250к от кандидата на 450к.
1. Три правила владения и что реально происходит на ассемблере.
Три правила владения Rust: 1) каждое значение имеет ровно одного владельца, 2) когда владелец выходит из скоупа, значение уничтожается, 3) владение может быть перемещено (move) или заимствовано (&T / &mut T). На ассемблерном уровне "владение" – это статическое отслеживание компилятором, кто отвечает за вызов деструктора.
Move – обычный memcpy стекового представления (ptr+len+cap для String = 24 байта на x86_64) плюс инвалидация исходника в typeck. В release-сборке на ARM/x86 move часто оптимизируется до нуля инструкций, если результат не используется или значение помещается в регистры. Заимствование (&T) – передача адреса без копирования данных: компилятор проверяет liveness через borrow checker, гарантируя что ссылка не переживёт данные.
Borrow checker работает только на уровне компилятора – в скомпилированном коде нет runtime-проверок lifetime-ов. Три правила – основа всей системы: без GC, без счётчика ссылок (не считая Rc/Arc), без runtime-overhead для базовых операций.
fn main() { // Move: 24 байта стекового представления копируются let s1 = String::from("hello"); // heap: ptr + len + cap на стеке let s2 = s1; // memcpy 24 байт, s1 инвалидируется // println!("{}", s1); // error: borrow of moved value // В release-сборке этот move часто = 0 инструкций: // s2 просто занимает тот же регистр что s1 // Borrow: только адрес, данные не копируются let s3 = String::from("world"); let r1 = &s3; // r1 = адрес s3, без heap-аллокации let r2 = &s3; // ок: много читателей println!("{} {}", r1, r2); // r1 и r2 не переживут s3 - гарантия borrow checker // Drop: вызывается в конце скоупа LIFO { let temp = String::from("temporary"); println!("{}", temp); } // drop(temp) здесь - освобождение heap // После drop s3 и s2 - в конце main }
2. Move vs Copy: разница только в одном бите тайп-системы.
На уровне ассемблера move и copy идентичны – это memcpy стекового представления. Разница исключительно в системе типов: наличие маркер-трейта Copy. Если Copy реализован – после присваивания p2 = p1 оба p1 и p2 валидны; если нет – p1 инвалидируется (borrow checker запрещает дальнейшее использование). Copy реализуется только для типов, которые можно безопасно скопировать побайтово: все числа (i32, f64, bool, char, указатели *const T), ссылки &T, массивы [T; N] если T: Copy, кортежи (A, B) если оба A, B: Copy.
Copy несовместим с Drop: если тип реализует Drop (есть деструктор), его нельзя Copy – потому что при double-copy деструктор вызвался бы дважды для одного ресурса. Компилятор проверяет это через E0184/E0204. Clone – более широкий трейт: явное глубокое копирование по запросу; Copy – неявное при присваивании. Все Copy-типы обязаны реализовать Clone, обратное неверно.
#[derive(Copy, Clone)] struct Point { x: i32, y: i32 } // Copy: оба поля Copy, нет Drop let p1 = Point { x: 1, y: 2 }; let p2 = p1; // memcpy 8 байт println!("{:?}", p1); // OK: p1 валиден - Copy let s1 = String::from("hi"); let s2 = s1; // move: только ptr+len+cap (24 байта) скопированы // println!("{}", s1); // E0382: use of moved value // Явное клонирование (глубокое копирование): let s3 = String::from("hi"); let s4 = s3.clone(); // heap-данные тоже копированы println!("{} {}", s3, s4); // оба валидны // Нельзя Copy если есть Drop: struct WithDrop(String); impl Drop for WithDrop { fn drop(&mut self) { println!("dropped"); } } // #[derive(Copy)] // E0184: Cannot implement Copy - implements Drop // impl Copy for WithDrop {} // E0184 // Copy для составных типов: #[derive(Copy, Clone)] struct Pair(i32, f64); // OK: i32 и f64 оба Copy // Проверить реализацию Copy: fn require_copy<T: Copy>(_: T) {} require_copy(42i32); require_copy(Point { x: 0, y: 0 }); // require_copy(String::new()); // E0277: String не Copy
3. Правило XOR: aliasing-модель и noalias в LLVM.
В каждой точке программы для каждого участка памяти существует либо одна &mut T, либо сколько угодно &T - но не оба одновременно. Это не просто синтаксическое правило: компилятор транслирует &mut T в LLVM-указатель с атрибутом noalias, что разрешает агрессивные оптимизации вроде переноса чтений за пределы цикла.
Нарушение этого инварианта через unsafe = UB, причём UB не пойманное санитайзером, потому что оптимизатор уже переписал код исходя из noalias-гарантии. С 2022 года Rust использует Stacked Borrows / Tree Borrows для формализации модели.
fn modify(v: &mut Vec<i32>) { v.push(1); // компилятор может переставить любые чтения вокруг этой строки, // потому что знает: никто другой не имеет доступа к v } let mut v = vec![1, 2, 3]; let r1 = &v; // let r2 = &mut v; // E0502: cannot borrow as mutable println!("{:?}", r1);
4-5. Лайфтаймы и elision: три правила, которые надо помнить наизусть.
Lifetime - это статический параметр, описывающий регион валидности ссылки. Borrow checker проверяет, что фактический регион использования не выходит за рамки региона валидности. Аннотация не продлевает жизнь данных, она описывает существующие отношения. Elision - синтаксический сахар, работающий по трём правилам: (1) каждая входная ссылка получает свой лайфтайм; (2) если входная ссылка одна - её лайфтайм идёт на все выходные; (3) если есть &self или &mut self - его лайфтайм идёт на все выходные.
// Elided: fn first_word(s: &str) -> &str { /* ... */ s } // Раскрытое: fn first_word<a>(s: &str) -> &str { s } // Тут elision не сработает - две входные ссылки: fn longest<a>(x: &str, y: &str) -> &str { if x.len() > y.len() { x } else { y } } // Метод - выходной лайфтайм берётся из &self: impl Parser { fn name(&self, _input: &str) -> &str { &self.name } }
6. static имеет два разных смысла - путать опасно.
Первый смысл – лайфтайм 'static на ссылке: &'static T означает «ссылка на данные, которые живут всю программу». Типичные примеры: строковые литералы ("hello" имеет тип &'static str), статические переменные (static X: i32 = 5), Box::leak(). Второй смысл – трейт-bound T: 'static означает «тип не содержит ссылок с не-static лайфтаймом» – то есть тип либо не имеет ссылок совсем, либо все ссылки 'static.
String: 'static (истинно) – String владеет данными, не содержит ссылок. &str: 'static только если конкретная &str – 'static. Arc<&'a str> НЕ 'static если 'a не 'static. Почему важно: tokio::spawn требует Future: Send + 'static – значит future не должен содержать ссылок на стек вызывающего потока (стек может быть уничтожен).
std::any::Any требует 'static. thread::spawn требует F: Send + 'static. 'static на bound – это НЕ «объект живёт вечно», это «объект может жить вечно, если нужно».
// &'static str - ссылка на данные в .rodata секции бинарника: let s: &'static str = "hello world"; // строковый литерал - всегда 'static // static переменная: static GREETING: &str = "Hi there"; // &'static str // T: 'static - тип не имеет не-static ссылок: fn spawn_owned<T: Send + 'static>(val: T) { std::thread::spawn(move || println!("got value")); } // String: 'static (владеет данными, нет ссылок): spawn_owned(String::from("hi")); // OK // &str с нестатическим лайфтаймом: НЕ 'static fn fails_static() { let s = String::from("local"); // spawn_owned(&s[..]); // E0597: s не живёт достаточно долго // &s - ссылка на локальную переменную = не 'static } // Box::leak - создать 'static ссылку в рантайме (редко нужно): let leaked: &'static str = Box::leak(String::from("dynamic").into_boxed_str()); // tokio::spawn требует 'static: // async fn broken(s: &str) { tokio::spawn(async { println!("{}", s); }); } // E0373: s не 'static - future переживёт s async fn correct(s: String) { // String: 'static tokio::spawn(async move { println!("{}", s); }); }
7. NLL и Polonius: почему теперь компилируется то, что раньше нет.
До 2018 edition borrow checker работал на AST и считал лайфтайм по лексическому скоупу – лайфтайм ссылки формально жил до конца фигурной скобки, в которой была объявлена, даже если реально последний раз использовалась на три строки выше. Это часто отвергало корректный и безопасный код. С 2018 edition включён NLL (Non-Lexical Lifetimes): borrow checker перенесён на MIR (Mid-level Intermediate Representation) и вычисляет лайфтайм по точке последнего использования в CFG (control-flow graph), не по синтаксическим скобкам.
Это позволяет множеству паттернов – например, взять ссылку, использовать её, и потом мутировать источник – компилироваться корректно. Polonius – следующий шаг: формализует borrow-checking как Datalog-запросы к базе фактов о потоке данных. Позволяет принимать ещё больше программ – в частности условный возврат ссылок из веток match, где borrow checker видит «потенциальный конфликт», которого нет на самом деле.
В 2026 Polonius остаётся за флагом -Zpolonius и движется к стабилизации. Главный ответ на собесе: NLL = лайфтайм заканчивается после последнего использования, а не в конце лексического скоупа.
let mut v = vec![1, 2, 3]; let r = &v[0]; println!("{}", r); // последнее использование r v.push(4); // OK с NLL: r больше не активен // До NLL (pre-2018): E0502/E0505 потому что r "живёт до конца лексического скоупа"
8-9. Box, Rc, Arc: когда и почему.
Box<T> – единичное владение в куче: компилятор автоматически вызывает drop и освобождает heap-аллокацию при выходе из скоупа. Это не fat-pointer – size_of::<Box<T>>() = 8 байт (один указатель). Применяется для DST (Box<dyn Trait>, Box<[T]>), рекурсивных типов (linked list, tree), и heap-аллокации с уникальным владением.
Rc<T> – shared-владение с неатомарным счётчиком ссылок (u32). Инкремент ~1нс против ~5нс у Arc за счёт отсутствия атомарных инструкций. Не реализует Send/Sync – строго однопоточный. Используется в однопоточных деревьях, DAG, GUI-виджетах. Arc<T> – shared-владение с двумя AtomicUsize счётчиками: strong_count (живые Arc) и weak_count (живые Weak + 1 если strong > 0).
Strong-инкремент: Relaxed (только атомарность, порядок не важен). Strong-декремент: Release (чтобы все записи в T были видны). Финальный декремент: Acquire-fence перед вызовом drop(T) – гарантирует видимость всех Release-записей. При T: Send+Sync, Arc<T>: Send+Sync. Циклы Arc/Rc не собираются – GC в Rust нет.
Разрыв цикла – Weak<T>: не инкрементит strong_count, не удерживает данные. weak.upgrade() возвращает Option<Arc<T>> – None если объект уже дропнут
use std::rc::{Rc, Weak}; use std::cell::RefCell; struct Node { parent: RefCell<Weak<Node>>, // слабая ссылка - не держит children: RefCell<Vec<Rc<Node>>>, // сильные value: i32, } // Если поменять Weak на Rc - получим цикл parent -> child -> parent // и память никогда не освободится
10. Interior mutability: единственный легальный путь к &mut через &.
Interior mutability – паттерн, при котором изменение данных происходит через разделяемую ссылку &T вместо &mut T. Это не обход borrow-checker: все правила соблюдаются, просто проверка переносится с compile-time на runtime или реализуется через атомарные операции. Cell<T> – для Copy-типов: get()/set()/replace() через копирование, никакого runtime-оверхеда на проверки, не Sync.
RefCell<T> – runtime-проверка правила XOR через счётчик borrow-ов: или одна RefMut, или много Ref, паника при нарушении (try_borrow возвращает Result вместо паники). Mutex<T>/RwLock<T> – те же гарантии, но через OS-примитивы, Sync, подходят для multi-thread.
OnceCell/OnceLock – инициализация ровно один раз. LazyLock<T> (стабилен с 1.80) – ленивая вариант OnceLock с замыканием инициализации внутри – заменяет once_cell::sync::Lazy, стандартный способ для статически инициализируемых глобальных переменных. Выбор: Cell для простых Copy-полей структуры (флаги, счётчики), RefCell для однопоточных графов и деревьев, Mutex для shared state между потоками.
use std::cell::{Cell, RefCell}; use std::rc::Rc; #[derive(Debug)] struct Config { debug: Cell<bool>, // Copy-тип: простая замена без overhead data: RefCell<Vec<i32>>, // runtime borrow-checking } impl Config { fn new() -> Self { Config { debug: Cell::new(false), data: RefCell::new(vec![]), } } fn enable_debug(&self) { // &self, не &mut self! self.debug.set(true); } fn push(&self, val: i32) { // &self, не &mut self! self.data.borrow_mut().push(val); // паника если уже есть borrow_mut } fn len(&self) -> usize { self.data.borrow().len() // разделяемый borrow } } fn main() { let cfg = Rc::new(Config::new()); cfg.enable_debug(); cfg.push(42); println!("debug={}, len={}", cfg.debug.get(), cfg.len()); }
11. Cow: когда оно реально экономит.
Cow<'a, B> (Clone-on-Write) – умный указатель с двумя вариантами: Borrowed(&'a B) – заимствованные данные без аллокации, и Owned(<B as ToOwned>::Owned) – владеющая копия. Работает для любого типа, реализующего ToOwned: чаще всего Cow<'_, str> (между &str и String) и Cow<'_, [T]> (между &[T] и Vec<T>).
Реальная экономия: функция получает строку, в 90% случаев возвращает её без изменений – вместо того чтобы клонировать всегда, Cow хранит ссылку и клонирует только когда нужна мутация (.to_mut()). Типичные паттерны: нормализация строк (lowercase/trim – редко нужна), конфигурационные значения с дефолтами, парсеры где большинство токенов – это срезы входных данных.
Метод Cow::into_owned() возвращает String или Vec – клонирует если Borrowed, перемещает если Owned.
use std::borrow::Cow; // Функция: нормализуй строку если нужно, иначе верни как есть fn normalize(input: &str) -> Cow<'_, str> { if input.chars().all(|c| c.is_lowercase()) { Cow::Borrowed(input) // нет аллокации - вернём ссылку } else { Cow::Owned(input.to_lowercase()) // аллокация только если нужна } } // Cow в структуре для конфиг-значений struct Config<'a> { name: Cow<'a, str>, // может быть &'static str или String } fn main() { let s1 = "hello"; // уже lowercase let s2 = "HELLO"; // нужна нормализация let n1 = normalize(s1); // Borrowed - нет аллокации let n2 = normalize(s2); // Owned - аллокация String println!("{} {}", n1, n2); // into_owned: клонирует если нужно, перемещает если Owned let owned: String = n1.into_owned(); // клонирует "hello" let owned2: String = n2.into_owned(); // перемещает, без клона // Cow в конфиге: статические или динамические значения let cfg = Config { name: Cow::Borrowed("default") }; let cfg2 = Config { name: Cow::Owned(format!("user_{}", 42)) }; println!("{} {}", cfg.name, cfg2.name); }
12. Drop руками не вызвать - и это правильно.
Вызов obj.drop() напрямую – запрещён компилятором. Причина: если бы вы вызвали drop вручную, а потом переменная вышла из скоупа, деструктор вызвался бы дважды – double-free, UB. Чтобы явно уничтожить значение досрочно, используйте drop(obj) из стандартной библиотеки – это тривиальная функция fn drop<T>(_: T) {}, которая принимает значение по владению и сразу его дропает.
После этого переменная moved, компилятор не даст её использовать. Порядок дропа: поля структуры дропаются в порядке объявления; локальные переменные – в обратном порядке объявления (LIFO). Это критично для MutexGuard: если хотите отпустить лок до конца блока, нужно явно drop(guard). ManuallyDrop<T> полностью отключает автоматический деструктор – нужно самому вызвать unsafe { ManuallyDrop::drop(&mut val) }. Используется в unsafe-коде для передачи владения через FFI или в кастомных аллокаторах.
use std::mem::ManuallyDrop; use std::sync::Mutex; struct Resource(String); impl Drop for Resource { fn drop(&mut self) { println!("dropping: {}", self.0); } } fn main() { let r1 = Resource("first".into()); let r2 = Resource("second".into()); // r2 дропается первым (LIFO): "second", затем "first" // Явный досрочный drop let r3 = Resource("early".into()); drop(r3); // "dropping: early" здесь println!("r3 уже уничтожен"); // ManuallyDrop: отключить автоматический деструктор let r4 = ManuallyDrop::new(Resource("manual".into())); // деструктор r4 НЕ вызовется автоматически - память утечёт если не позаботиться unsafe { ManuallyDrop::drop(&mut ManuallyDrop::new(Resource("manual2".into()))) }; // Mutex: освобождение гарда до конца блока let m = Mutex::new(42); let guard = m.lock().unwrap(); println!("under lock: {}", *guard); drop(guard); // освобождаем лок здесь, а не в конце функции println!("lock released"); }
13. Fn / FnMut / FnOnce: иерархия и реальный захват.
Три трейта замыканий – иерархия по силе ограничений на захваченные переменные. FnOnce – может быть вызвано только один раз: захватывает переменные по владению (move), после вызова они уничтожаются или перемещены. Любое замыкание реализует FnOnce. FnMut – может быть вызвано несколько раз с изменением захваченного: захватывает по &mut.
Реализует FnOnce. Fn – может вызываться параллельно: захватывает по & или не захватывает вовсе. Реализует FnMut и FnOnce. Компилятор автоматически определяет наименее ограниченный трейт по телу замыкания. move || – переносит все захваченные переменные по владению (нужно для передачи в поток, tokio::spawn).
Важно: move не делает замыкание FnOnce автоматически – если тело только читает, оно реализует Fn даже с move. Принимайте impl Fn когда только вызываете, impl FnMut если изменяете состояние, impl FnOnce если вызываете только один раз.
fn call_once(f: impl FnOnce() -> String) -> String { f() } fn call_mut(mut f: impl FnMut() -> i32) -> [i32; 3] { [f(), f(), f()] } fn call_fn(f: impl Fn() -> i32) -> i32 { f() + f() } fn main() { let s = String::from("hello"); // FnOnce: перемещает s - нельзя вызвать дважды let once = move || s + " world"; // s moved в замыкание println!("{}", call_once(once)); // FnMut: изменяет счётчик let mut count = 0; let mut counter = || { count += 1; count }; // &mut count println!("{:?}", call_mut(&mut counter)); // [1, 2, 3] println!("final count: {}", count); // 3 // Fn: только читает let base = 10; let adder = |x| base + x; // &base (immutable) println!("{}", call_fn(|| adder(5))); // 30 // move + Fn: перемещает но только читает (Send-safe) let data = vec![1, 2, 3]; let reader = move || data.len(); // data owned, но Fn std::thread::spawn(reader).join().unwrap(); // Send ok }
14. PhantomData: маркер с четырьмя суперспособностями.
PhantomData<T> – тип нулевого размера (ZST), влияющий на четыре подсистемы компилятора: (1) variance – PhantomData<T> ковариантен по T (как &T), PhantomData<&mut T> инвариантен, PhantomData<fn(T)> контравариантен; (2) dropck – говорит бorrow checker что структура «владеет» T и может обращаться к T в Drop::drop; (3) auto traits – поле *const () снимает Send и Sync автоматически; (4) связывание неиспользуемых параметров – без PhantomData компилятор выдаёт E0392 «parameter T is never used».
Без PhantomData<T> в структуре с *mut T у компилятора нет информации о variance и dropck – это soundness-дыра в unsafe-коде. PhantomData не занимает памяти в рантайме: size_of::<PhantomData<Vec<u64>>>() == 0. Типичные паттерны: обёртка над сырым указателем, state-machine с типовыми состояниями, маркер потокобезопасности.
use std::marker::PhantomData; use std::ptr::NonNull; // 1. Ковариантный умный указатель (как &T): struct OwnedPtr<T> { ptr: NonNull<T>, // ненулевой сырой указатель _owns: PhantomData<T>, // ковариантен + dropck знает что дропаем T } // 2. Инвариантный: &mut T - нельзя менять тип через subtyping struct MutPtr<T> { ptr: *mut T, _inv: PhantomData<*mut T>, // *mut T - инвариантен по T } // 3. Снять Send/Sync: для однопоточных объектов struct ThreadLocal<T> { value: T, _no_send: PhantomData<*const ()>, // *const () - !Send и !Sync } // 4. Typestate - состояние как тип, нулевая стоимость в рантайме struct Connection<State> { fd: i32, _state: PhantomData<State>, // не занимает памяти! } // Проверка нулевого размера: use std::mem::size_of; assert_eq!(size_of::<PhantomData<Vec<u64>>>(), 0); assert_eq!(size_of::<Connection<u128>>(), size_of::<i32>()); // только fd
15. Borrow / AsRef / Deref: три похожих трейта и когда какой.
Deref<Target = U> - "прозрачное разыменование" smart pointer, активируется автоматически при . и *. AsRef<U> - "дешёвое преобразование &Self в &U", вызывается явно, не активирует deref-coercion в подписях. Borrow<U> - то же что AsRef плюс жёсткий контракт: Hash/Eq/Ord для Self и U должны совпадать. Borrow нужен в HashMap::get, чтобы можно было искать по &str в HashMap<String, _>.
use std::collections::HashMap; let mut map: HashMap<String, i32> = HashMap::new(); map.insert("key".to_string(), 42); // String: Borrow<str> - можно искать по &str без аллокации: let v = map.get("key"); // не требует String::from // Box<T>: Deref<Target = T> - точка работает автоматически: let b = Box::new(vec![1, 2, 3]); let len = b.len(); // эквивалентно (*b).len()
Самые частые грабли блока на собеседовании.
Итерация и мутация вектора одновременно (E0502 - решается retain / drain_filter / сбором изменений в отдельный Vec). Возврат &static str из функции, которая собирает строку в рантайме (надо либо Cow, либо String, либо Box::leak для редких случаев). Попытка реализовать Copy для типа с Drop (нельзя by design). Self-referential структуры без Pin (классический FAQ для async). Сравнение Rc с Arc по производительности без учёта того, что Rc просто не Send.
// Граблина 1: Итерация и мутация вектора (E0502) let mut v = vec![1, 2, 3, 4, 5]; // v.retain(|&x| x % 2 == 0); // правильно: in-place фильтрация let evens: Vec<i32> = v.iter().filter(|&&x| x % 2 == 0).copied().collect(); // Граблина 2: &'static str из рантаймовых данных fn get_name(id: u32) -> &'static str { // ОШИБКА: не можем вернуть &str из String // let s = format!("user_{}", id); // String живёт только в функции // &s // E0106: borrowed value does not live long enough "static_name" // только если строка статическая } // Правильно: вернуть String или Cow<'static, str> fn get_name_ok(id: u32) -> String { format!("user_{}", id) } // Граблина 3: Rc не Send - не подходит для tokio::spawn use std::rc::Rc; // let rc = Rc::new(42); // tokio::spawn(async move { println!("{}", rc); }); // E0277: Rc is not Send // Правильно: использовать Arc use std::sync::Arc; let arc = Arc::new(42); // tokio::spawn(async move { println!("{}", arc); }); // OK
Блок 2: типы, трейты, обобщения (16-30)
Тут собеседующие любят валить на деталях вроде object-safety и orphan-rule. Школьное "trait это интерфейс" не работает - в Rust это другая алгебра.
16. Struct vs enum: алгебра типов и niche-оптимизация.
Struct - произведение типов: все поля существуют одновременно. Enum - сумма типов: ровно один вариант активен в момент времени. Память enum = max(размер варианта) + дискриминант, выровненный по правилам типа. Компилятор активно использует niche-оптимизацию: если в типе есть "невалидное" состояние (null-указатель, не-UTF8 байт в bool), дискриминант прячется туда. Поэтому Option<&T> занимает столько же байт, сколько &T - None кодируется null-указателем.
use std::mem::size_of; assert_eq!(size_of::<&u32>(), 8); assert_eq!(size_of::<Option<&u32>>(), 8); // niche! assert_eq!(size_of::<Option<Box<u32>>>(), 8); // niche! // А вот тут niche нет: assert_eq!(size_of::<u32>(), 4); assert_eq!(size_of::<Option<u32>>(), 8); // 4 + дискр + padding
17-18. Трейты и диспетчеризация: моно vs vtable.
Трейт это не Java-интерфейс: реализацию можно писать в другом крейте при соблюдении orphan-rule, и трейт может иметь дефолтные методы и ассоциированные типы. Статическая диспетчеризация (impl Trait, generic) = мономорфизация: компилятор генерирует отдельную копию функции под каждый конкретный тип. Быстро, инлайнится, раздувает бинарник.
Динамическая (dyn Trait) = vtable-указатель плюс data-указатель, fat-pointer 16 байт на x86_64. Медленнее на ~2-5нс на вызов, но позволяет гетерогенные коллекции и снижает размер бинарника.
trait Greet { fn say(&self); } // Static dispatch: одна копия на каждый T fn static_greet<T: Greet>(t: &T) { t.say(); } // Dynamic dispatch: одна функция, vtable lookup в рантайме fn dyn_greet(t: &dyn Greet) { t.say(); } // Гетерогенный вектор - только через dyn: let v: Vec<Box<dyn Greet>> = vec![/* разные типы */]; // Размеры: // size_of::<&dyn Greet>() == 16 (data ptr + vtable ptr) // size_of::<&T>() == 8
19. Object safety: что мешает трейту стать dyn.
Трейт объектно-безопасен (object-safe), если компилятор может построить vtable для dyn Trait. Vtable – это таблица указателей на методы конкретного типа, а dyn Trait – жирный указатель (data ptr + vtable ptr). Правила объектной безопасности: 1) метод не должен быть generic (нельзя знать размер vtable заранее), 2) метод не должен возвращать Self (тип неизвестен через dyn), 3) метод не должен принимать Self по значению кроме как через Box, 4) трейт не должен иметь associated type без фиксации через where.
Clone не object-safe из-за метода clone(&self) -> Self. Чтобы трейт с нарушающими методами всё же использовался как dyn Trait, эти методы помечают where Self: Sized – они просто исчезают из vtable. Компилятор явно скажет: "the trait cannot be made into an object because..." с конкретной причиной.
// Не object-safe: метод generic trait BadTrait { fn process<T>(&self, val: T); // generic метод - нет в vtable } // Не object-safe: возвращает Self trait AlsoBad: Clone { fn clone_me(&self) -> Self; // неизвестный размер возврата } // Object-safe трейт trait Drawable { fn draw(&self); fn area(&self) -> f64; // Generic метод можно спрятать за where Self: Sized fn serialize<W: std::io::Write>(&self, w: &mut W) where Self: Sized {} } struct Circle { radius: f64 } struct Square { side: f64 } impl Drawable for Circle { fn draw(&self) { println!("circle r={}", self.radius); } fn area(&self) -> f64 { std::f64::consts::PI * self.radius * self.radius } } impl Drawable for Square { fn draw(&self) { println!("square s={}", self.side); } fn area(&self) -> f64 { self.side * self.side } } fn total_area(shapes: &[Box<dyn Drawable>]) -> f64 { shapes.iter().map(|s| s.area()).sum() }
20. Ассоциированный тип vs дженерик-параметр: семантика "функции на типах".
Ассоциированный тип = ровно одна реализация на тип. Iterator::Item для Vec<i32>::IntoIter всегда i32, ничего другого не подойдёт. Дженерик-параметр трейта = много реализаций на тип с разными параметрами: From<i32> for MyType и From<String> for MyType - две разные реализации.
Правило: если параметр однозначно определяется реализующим типом - ассоциированный, если может варьироваться - дженерик. Смежные трейты: TryFrom<T> и TryInto<T> – ошибающиеся варианты, возвращают Result<T, E>. Если реализован From<T> – Into<U> доступен автоматически (через blanket impl); аналогично для TryFrom/TryInto.
// Ассоциированный: одна реализация Iterator на тип trait Iterator { type Item; fn next(&mut self) -> Option<Self::Item>; } // Дженерик: можно реализовать From<A> и From<B> для одного типа trait From<T> { fn from(t: T) -> Self; } struct Money(u64); impl From<u32> for Money { fn from(c: u32) -> Self { Money(c as u64) } } impl From<u64> for Money { fn from(c: u64) -> Self { Money(c) } }
21-22. Orphan-rule и newtype: легальный путь к чужой реализации.
Orphan-rule: реализовать трейт T для типа U можно только если либо T, либо U определены в текущем крейте. Это даёт coherence: одна реализация на пару тип-трейт во всей программе. Обход - newtype: оборачиваем чужой тип в свою structкту-tuple-обёртку, теперь обёртка наша - можно реализовать что угодно. Bесплатно в рантайме: layout идентичен внутреннему типу, repr(transparent) гарантирует это формально.
// Нельзя: impl Display for Vec<u32> (оба не наши) // Можно через newtype: #[repr(transparent)] struct Hex(Vec<u8>); impl std::fmt::Display for Hex { fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { for b in &self.0 { write!(f, "{:02x}", b)?; } Ok(()) } } // size_of::<Hex>() == size_of::<Vec<u8>>() благодаря repr(transparent)
23. derive: какой трейт что требует.
Стандартные derive-макросы работают рекурсивно: для каждого поля выдаётся требование реализации того же трейта. Debug требует Debug у каждого поля; Clone требует Clone; Copy требует Copy у каждого поля и отсутствие Drop (если хоть одно поле !Copy или тип имеет Drop – E0204). Hash требует Hash; PartialEq и Eq требуют PartialEq/Eq у полей.
Важная консистентность: если derive(Hash) и derive(PartialEq) – hash должен совпадать с eq (одинаковые объекты дают одинаковый hash). Нельзя derive(Hash) и вручную реализовать PartialEq по-другому – это нарушение контракта HashMap/HashSet. Default требует Default у каждого поля (числа = 0, bool = false, String = "", Vec = []).
Для enum Default работает только с явным #[default] атрибутом на одном варианте (стабильно с 1.62). Снаружи стандарта: thiserror (Error + Display), serde::Serialize/Deserialize, strum (FromStr, Display для enum), derive_more (Add, Display, Into и ещё 20+ трейтов), bon/typed-builder (type-safe Builder).
#[derive(Debug, Clone, PartialEq, Eq, Hash, Default)] struct Point { x: i32, y: i32 } // Эти производятся автоматически derive(Eq) при derive(PartialEq): // - PartialEq: fn eq(&self, other: &Self) -> bool { self.x == other.x && self.y == other.y } // - Hash: fn hash<H: Hasher>(&self, state: &mut H) { self.x.hash(state); self.y.hash(state); } // - Default: fn default() -> Self { Point { x: 0, y: 0 } } // ОШИБКА: нарушение Hash/Eq контракта #[derive(Hash)] struct BadKey { v: f32 } // f32 НЕ реализует Eq/Hash! Ошибка компиляции // Default для enum (stable 1.62+): #[derive(Default, Debug)] enum Status { #[default] // обязательно для enum Pending, Running, Done, } // thiserror - derive для ошибок библиотек: #[derive(thiserror::Error, Debug)] pub enum AppError { #[error("not found: {0}")] NotFound(String), #[error("io error: {0}")] Io(#[from] std::io::Error), // auto From<io::Error> #[error("invalid: {msg}")] Invalid { msg: String }, } // Нельзя Copy если есть Drop: struct Counter(Vec<u8>); // Vec имеет Drop // #[derive(Copy)] // E0204: cannot implement Copy - Vec has Drop
24. impl Trait: четыре позиции с разной семантикой.
impl Trait в четырёх синтаксических позициях означает разные вещи. В позиции возврата (RPIT) – один конкретный, но непоименованный тип, экзистенциальный квантор: "существует некоторый тип, реализующий Trait, и это он". Все ветки функции должны возвращать один и тот же конкретный тип. Полезно для возврата сложных итераторов и замыканий без лишних Box.
В аргументах – синтаксический сахар над дженериком: fn f(x: impl Trait) эквивалентно fn f<T: Trait>(x: T), каждый вызов мономорфизируется отдельно. В associated type (TAIT, nightly) – настоящий экзистенциальный тип уровня модуля. В трейт-объектах dyn Trait – динамическая диспетчеризация через vtable (в отличие от impl Trait, который всегда статический).
Ключевое отличие от dyn: impl Trait – нулевой оверхед, конкретный тип известен компилятору; dyn Trait – жирный указатель плюс косвенный вызов через vtable.
// RPIT: возврат impl Iterator - один конкретный тип fn evens(n: usize) -> impl Iterator<Item = usize> { (0..n).filter(|x| x % 2 == 0) // конкретный тип Filter<Range<usize>, _> } // В аргументах: сахар над дженериком, мономорфизируется fn print_len(s: impl AsRef<str>) { println!("{}", s.as_ref().len()); } // Возврат замыкания - без impl Trait не обойтись (тип безымянный) fn make_adder(x: i32) -> impl Fn(i32) -> i32 { move |y| x + y } // dyn Trait - гетерогенная коллекция, оверхед vtable fn total(items: &[Box<dyn std::fmt::Display>]) { for item in items { println!("{}", item); } } fn main() { let add5 = make_adder(5); println!("{}", add5(3)); // 8 let sum: usize = evens(10).sum(); println!("{}", sum); // 20 }
25. dyn Trait в поле: указатель обязателен.
dyn Trait – DST (Dynamically Sized Type): размер неизвестен в compile-time, поэтому нельзя положить по значению в struct-поле или стек. Единственный способ: за указателем – Box<dyn Trait>, Rc<dyn Trait>, Arc<dyn Trait>, &dyn Trait. Box<dyn Trait> – владение, Arc<dyn Trait> – shared ownership с потокобезопасностью.
size_of::<&dyn Trait>() == 16: fat pointer = (data ptr + vtable ptr). Каждый dyn Trait несёт vtable: массив указателей на методы конкретного типа + drop glue + size/align. Для DI (Dependency Injection) в Rust идиоматично: Box<dyn Trait + Send + Sync> или Arc<dyn Trait + Send + Sync> с 'static.
Тонкость: dyn Trait + Send + Sync + 'static – три auto-trait можно комбинировать. Downcast обратно к конкретному типу: через Any::downcast_ref или через std::any. Производительность: vtable lookup ~2-5нс vs inline функции, обычно незначим, важен только в tight loop.
use std::sync::Arc; trait Logger: Send + Sync { fn log(&self, msg: &str); fn level(&self) -> &str { "INFO" } // дефолтная реализация } // Dependency Injection через dyn Trait: struct Service { logger: Box<dyn Logger>, // owned, single-threaded metrics: Arc<dyn Logger>, // shared, multi-threaded } impl Service { fn new(l: impl Logger + 'static) -> Self { let arc_l = Arc::new(l) as Arc<dyn Logger>; Self { logger: Box::new(ConsoleLogger), metrics: arc_l, } } fn work(&self) { self.logger.log("working"); self.metrics.log("metric"); } } struct ConsoleLogger; impl Logger for ConsoleLogger { fn log(&self, msg: &str) { println!("[{}] {}", self.level(), msg); } } // Гетерогенная коллекция - только через dyn: let loggers: Vec<Box<dyn Logger>> = vec![ Box::new(ConsoleLogger), Box::new(ConsoleLogger), ]; for l in &loggers { l.log("test"); } // Размер fat pointer: use std::mem::size_of; assert_eq!(size_of::<&dyn Logger>(), 16); // ptr + vtable assert_eq!(size_of::<Box<dyn Logger>>(), 16); // то же самое
26. Sized и ?Sized: тонкость дженериков.
По умолчанию каждый тип-параметр T имеет неявный bound T: Sized. Sized означает «размер известен в compile-time». Это удобно: можно создать T по значению, положить в стек. DST (Dynamically Sized Types) – str, [T], dyn Trait – размер неизвестен, их нельзя положить по значению. ?Sized явно «отменяет» bound Sized, разрешая работать с DST, но только через указатель: &T, Box<T>, Arc<T>.
Классический пример: fn print<T: ?Sized + fmt::Debug>(t: &T) работает и для &str, и для &[i32], и для &dyn Debug – всех DST. Без ?Sized нельзя: impl AsRef<str> for str, Box<dyn Trait> (dyn Trait: !Sized). Тонкость: Sized – это авто-трейт, его нельзя реализовать вручную.
Box<T> определён как struct Box<T: ?Sized>, именно поэтому Box<dyn Trait> работает. str и [T] – единственные built-in DST в языке; dyn Trait – third.
use std::fmt::Debug; // Без ?Sized - T обязан быть Sized, DST не подходит: fn print_sized<T: Debug>(t: &T) { println!("{:?}", t); } // С ?Sized - работает для любого DST: fn print_any<T: ?Sized + Debug>(t: &T) { println!("{:?}", t); } print_any("hello"); // &str - DST (str: !Sized) print_any(&[1, 2, 3][..]); // &[i32] - DST print_any(&42i32); // &i32 - обычный тип, тоже работает // Box определён с ?Sized - именно поэтому Box<dyn Trait> компилируется: // pub struct Box<T: ?Sized, A: Allocator = Global> { ... } let b: Box<dyn Debug> = Box::new(42i32); // dyn Debug: !Sized, но Box<T: ?Sized> OK // str: !Sized - нельзя по значению, только по указателю: // let s: str = *"hello"; // E0277: str не Sized let s: &str = "hello"; // OK: &str - fat pointer (ptr + len) let s: Box<str> = Box::from("hello"); // OK: Box<str> - heap + fat pointer // impl AsRef<str> работает потому что AsRef<T: ?Sized>: // pub trait AsRef<T: ?Sized> { fn as_ref(&self) -> &T; } let s = String::from("hi"); let r: &str = s.as_ref(); // String: AsRef<str>
27. where: не только для длинных bound-ов.
where-clause – не просто синтаксический сахар для длинных сигнатур. Это единственный способ выразить три класса ограничений: (1) bounds на ассоциированных типах – T: Iterator, T::Item: Display нельзя записать в синтаксисе угловых скобок в старых версиях; (2) HRTB (Higher-Ranked Trait Bounds) – for<'a> F: Fn(&'a str) – нельзя без where; (3) bounds на конкретных типах, не параметрах – (A, B): Debug, Vec<T>: Clone.
where также позволяет писать несколько bounds на ассоциированных типах нескольких итераторов одновременно – без него код превращается в нечитаемое месиво угловых скобок. Конвенция: если bounds умещается в 1 строку – в угловых скобках, если длиннее или содержит ассоциированные типы – where.
// 1. bounds на ассоциированных типах - только through where fn sum_display<I>(iter: I) -> String where I: Iterator, // трейт-bound на параметре I::Item: std::fmt::Display, // bound на ассоциированном типе { iter.map(|x| x.to_string()).collect::<Vec<_>>().join(", ") } // 2. HRTB: F работает для ЛЮБОГО лайфтайма 'a fn apply_to_ref<F, T>(f: F, data: &[T]) -> usize where F: for<'a> Fn(&'a [T]) -> usize, // нельзя без where { f(data) } // 3. bounds на составных типах, не параметрах: fn zip_debug<A, B>(a: A, b: B) -> impl std::fmt::Debug where (A, B): std::fmt::Debug, // bound на кортеже - нельзя в <> { (a, b) } // 4. Несколько bounds с ассоциированными типами - читаемо через where: fn merge<L, R>(left: L, right: R) where L: Iterator, L::Item: Clone + std::fmt::Debug, R: Iterator<Item = L::Item>, { for (l, r) in left.zip(right) { println!("{:?} {:?}", l, r.clone()); } }
28. Coherence и почему специализация всё ещё не стабильна.
Coherence – формальное свойство системы трейтов: для любой пары (тип, трейт) во всей программе существует не более одной реализации. Держится на двух столпах: orphan-rule (реализовать можно только если тип или трейт «свой») и запрете перекрывающихся реализаций (нельзя написать impl<T> Foo for T и отдельно impl Foo for String – они пересекаются).
Specialization – возможность сказать «для всех T используй A, но для String переопредели на B» – нарушает coherence в наличии auto traits и лайфтаймов. Проблема: если есть impl<T: Clone> Trait for T и impl Trait for Vec<i32> – они конфликтуют, потому что компилятор не может статически доказать отсутствие перекрытия с учётом того что Vec<i32> реализует Clone.
Lattice specialization (2024+) позволяет частичное перекрытие при строгой иерархии, min_specialization стабилизирована ограниченно для компилятора rustc. Полная specialization RFC #1210 открыт с 2015 – проблема стоит сложнее, чем казалось изначально. Практическое следствие: нельзя написать «умолчание плюс специализация» в стабильном Rust; обходной путь – newtype или proc-macro.
// Coherence: только одна реализация на пару (тип, трейт) trait Summarize { fn summary(&self) -> String; } // Нельзя: оба impl пересекаются - Vec<i32> удовлетворяет оба условия // impl<T: Clone> Summarize for T { fn summary(&self) -> String { "clone".into() } } // impl Summarize for Vec<i32> { fn summary(&self) -> String { "vec".into() } } // E0119: conflicting implementations of trait Summarize for type Vec<i32> // Orphan rule: можно реализовать если хотя бы одно из двух - наше pub struct Wrapper(Vec<i32>); impl Summarize for Wrapper { // OK: Wrapper - наш тип fn summary(&self) -> String { format!("{} items", self.0.len()) } } impl std::fmt::Display for Wrapper { // OK: Wrapper - наш тип fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { write!(f, "{:?}", self.0) } } // min_specialization (нестабильно) - используется внутри компилятора rustc: // #![feature(min_specialization)] // impl<T> ToString for T where T: fmt::Display { default fn to_string(&self) -> String { ... } } // impl ToString for str { fn to_string(&self) -> String { self.to_owned() } } // специализация
29. Supertrait: обязательство и доступ.
Supertrait – трейт, реализация которого обязательна для реализации дочернего трейта. Синтаксис: trait Child: Parent. Это НЕ наследование методов: реализующий тип обязан реализовать Parent сам (или через derive), но методы Parent автоматически «в области видимости» в теле методов Child через self. Главные use cases: составные трейты (Eq: PartialEq – не бывает равенства без частичного равенства), требование к реализатору (Iterator обязывает реализовать next; все 70+ адапторов – дефолтные методы на основе next), упрощение bounds в дженериках (один bound Child вместо Child + Parent + GrandParent).
Ошибка на собесе: путают с ООП-наследованием. В Rust нет наследования полей/методов – только наследование обязательств. Ещё ошибка: не понимают почему Ord требует Eq: Eq: PartialEq (полное равенство), Ord: Eq + PartialOrd (полный порядок требует и полного равенства).
// Определяем иерархию трейтов trait Animal: std::fmt::Display + std::fmt::Debug { fn name(&self) -> &str; fn sound(&self) -> &str; // Дефолтный метод использует Display (supertrait): fn introduce(&self) -> String { format!("I am {}, I go {}. Full info: {}", self.name(), self.sound(), self) } } #[derive(Debug)] struct Dog { name: String } // Обязаны реализовать Display (supertrait): impl std::fmt::Display for Dog { fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { write!(f, "Dog({})", self.name) } } // Теперь можем реализовать Animal: impl Animal for Dog { fn name(&self) -> &str { &self.name } fn sound(&self) -> &str { "woof" } // introduce() работает бесплатно через Display } // Иерархия из std: // PartialEq -> Eq (добавляет рефлексивность) // PartialOrd -> Ord (добавляет тотальный порядок, требует Eq) // Поэтому derive(Ord) требует: derive(PartialOrd, PartialEq, Eq) #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] struct Priority(u32); let mut tasks = vec![Priority(3), Priority(1), Priority(2)]; tasks.sort(); // работает потому что Ord + Eq
30. Blanket impl: мощь и ловушка.
Blanket implementation – реализация трейта для всех типов удовлетворяющих некоторому bound: impl<T: Display> MyTrait for T { ... }. Мощь: одна строка покрывает все существующие и будущие типы реализующие Display – не нужно реализовывать MyTrait для i32, String, f64 отдельно. Стандартная библиотека активно использует это: impl<T: Into<U>> From<T> for U – откуда берётся автоматическая конвертация; impl<T: Display> ToString for T – откуда у всех Display есть to_string().
Ловушка: blanket impl подчиняется orphan rule – реализовывать трейт для типа разрешено только если: трейт определён в вашем крейте, ИЛИ тип определён в вашем крейте. Blanket impl вида impl MyTrait for Vec<T> из стороннего крейта + impl MyTrait for Vec<T> из вашего крейта = orphan conflict.
Это намеренное ограничение для предотвращения конфликтов. Ещё ловушка: blanket impl может неожиданно покрыть ваш тип, конфликтуя с другой реализацией.
use std::fmt::Display; trait Summarize { fn summary(&self) -> String; } // Blanket impl: все Display-типы получают Summarize бесплатно impl<T: Display> Summarize for T { fn summary(&self) -> String { format!("Summary: {}", self) } } fn print_summary(item: &impl Summarize) { println!("{}", item.summary()); } fn main() { // Работает для всех Display-типов: print_summary(&42_i32); // "Summary: 42" print_summary(&"hello"); // "Summary: hello" print_summary(&3.14_f64); // "Summary: 3.14" // Из стд: impl<T: Into<U>> From<T> for U let s: String = String::from("hello"); // From через blanket let n: i64 = i64::from(42_i32); // From через blanket // impl<T: Display> ToString for T let s2 = 42.to_string(); // ToString через blanket на Display println!("{}", s2); }
31. Send и Sync: два auto-trait, на которых стоит вся многопоточность Rust.
Send означает «тип безопасно перемещать в другой поток» – то есть передача владения по каналу (mpsc::send), передача в tokio::spawn, join-handle. Sync означает «&T безопасно разделять между потоками» – можно иметь несколько &T из разных потоков одновременно. Формально: T: Sync если и только если &T: Send.
Оба трейта – auto-trait: компилятор выводит автоматически по составу полей. Если все поля Send – тип Send. Если хоть одно поле не Send – тип тоже не Send. Аналогично для Sync. Это позволяет компилятору статически гарантировать отсутствие гонок данных (data race) в safe Rust – центральная безопасностная гарантия. Rc<T> не Send: счётчик ссылок u32, не атомарный – два потока одновременно инкрементируют = гонка = UB.
Arc<T> Send при T: Send+Sync: атомарный счётчик. Cell<T> не Sync: позволяет мутацию через &self без синхронизации – два потока могут одновременно читать и писать = UB. RefCell<T> Send при T: Send, но не Sync – runtime borrow-check не потокобезопасен. tokio::spawn требует Future: Send + static именно для этого: гарантирует что future можно переместить на любой worker-поток.
Снять автовывод можно через поле PhantomData<*const ()> (не Send, не Sync) или явно unsafe impl Send / unsafe impl Sync с обоснованием в комментарии.
use std::rc::Rc; use std::sync::Arc; let rc = Rc::new(1); // std::thread::spawn(move || println!("{}", rc)); // E0277: Rc cannot be sent between threads safely let arc = Arc::new(1); std::thread::spawn(move || println!("{}", arc)); // OK // Snять Send/Sync вручную - только через unsafe: struct NotSend(*const u8); // unsafe impl Send for NotSend {} // явное обещание программиста
32. Mutex vs RwLock: контринтуитивная истина про "много читателей".
RwLock позволяет множеству читателей держать лок одновременно или одному писателю – эксклюзивно. Интуиция говорит: "много читателей, мало писателей = RwLock выигрывает". Контринтуитивная истина: на реальных нагрузках RwLock часто медленнее Mutex. Причины: 1) RwLock на большинстве платформ (pthreads rwlock) тяжелее Mutex по размеру и стоимости операций.
2) Более сложная логика locking – больший overhead даже для read-lock. 3) Writer starvation prevention требует дополнительных флагов. 4) Futex-оптимизация лучше разработана для Mutex. Когда RwLock реально выигрывает: критическая секция для чтения значимо длиннее, чем latency lock/unlock (то есть длиннее ~1 микросекунды), и читателей действительно много параллельных.
Для конфигурации которая читается редко-меняется – arc-swap или ArcSwap часто лучше обоих: атомарная замена указателя без любого блокирования на чтение. В Tokio: tokio::sync::RwLock для async-кода.
use std::sync::{Arc, Mutex, RwLock}; use std::thread; fn benchmark_mutex(data: Arc<Mutex<Vec<i32>>>, readers: usize) { let mut handles = vec![]; for _ in 0..readers { let d = Arc::clone(&data); handles.push(thread::spawn(move || { let guard = d.lock().unwrap(); let _sum: i32 = guard.iter().sum(); // read under mutex })); } for h in handles { h.join().unwrap(); } } fn benchmark_rwlock(data: Arc<RwLock<Vec<i32>>>, readers: usize) { let mut handles = vec![]; for _ in 0..readers { let d = Arc::clone(&data); handles.push(thread::spawn(move || { let guard = d.read().unwrap(); let _sum: i32 = guard.iter().sum(); // concurrent reads })); } for h in handles { h.join().unwrap(); } } // Для конфигурации: arc-swap - лучше обоих // use arc_swap::ArcSwap; // static CONFIG: Lazy<ArcSwap<Config>> = Lazy::new(|| ArcSwap::new(Arc::new(Config::default()))); // let config = CONFIG.load(); // атомарная загрузка, нет блокировки
33. Poisoning: что делать, когда тред упал под локом.
Если поток упал с паникой, удерживая Mutex, лок помечается как отравленный (poisoned). Все последующие вызовы .lock() возвращают Err(PoisonError), защищая от ситуации, когда данные внутри мьютекса могут быть в неконсистентном состоянии – паника могла прервать операцию на полпути.
Это сознательный дизайн: Rust вынуждает вас явно принять решение, что делать с возможно-повреждёнными данными. Большинство реальных программ либо завершаются при отравлении (.expect()), либо восстанавливают данные и продолжают через PoisonError::into_inner(). RwLock отравляется аналогично: при панике под write-локом (паника под read-локом НЕ отравляет RwLock).
try_lock() тоже возвращает Err на отравлённом локе. Отравление намеренно заразно: если вы хотите проигнорировать его, надо явно написать .unwrap_or_else(|e| e.into_inner()), что документирует намерение в коде.
use std::sync::{Arc, Mutex}; use std::thread; fn main() { let data = Arc::new(Mutex::new(vec![1, 2, 3])); let data2 = Arc::clone(&data); // Поток паникует под локом let _ = thread::spawn(move || { let mut guard = data2.lock().unwrap(); guard.push(4); panic!("упал под мьютексом!"); // лок отравлен }).join(); // Теперь мьютекс отравлен match data.lock() { Ok(guard) => println!("всё хорошо: {:?}", *guard), Err(poison) => { // Данные могут быть частично изменены (push(4) прошёл) let guard = poison.into_inner(); println!("восстановились: {:?}", *guard); // [1, 2, 3, 4] } } }
34. mpsc и его потомки.
std::sync::mpsc – Multi-Producer Single-Consumer канал: несколько отправителей (Sender, Clone-able), один получатель (Receiver). Два варианта: unbounded (sync_channel без буфера – нет) и bounded через sync_channel(n). Ограничения std::mpsc: нет select, нет async поддержки, производительность ниже альтернатив, нет явного обнаружения переполнения.
crossbeam-channel: MPMC (Multi-Consumer!), Select для нескольких каналов, bounded и unbounded, существенно быстрее std. tokio::sync::mpsc: async send/recv, bounded (backpressure) и unbounded, permit-API для backpressure без блокировки. tokio::sync::oneshot: ровно одно сообщение (запрос-ответ).
tokio::sync::broadcast: один producer, много получателей (каждый видит все сообщения); lagged() для обнаружения отставания. tokio::sync::watch: последнее значение (latest-value semantics); подходит для config reload, health state. Правило выбора: std::mpsc – только в no-async, crossbeam – sync multi-consumer, tokio – async, watch – последнее состояние, broadcast – события для всех.
use tokio::sync::{mpsc, oneshot, broadcast, watch}; // 1. mpsc bounded - backpressure встроен async fn mpsc_demo() { let (tx, mut rx) = mpsc::channel::<String>(100); // bounded = backpressure let tx2 = tx.clone(); // второй producer tokio::spawn(async move { tx.send("msg1".into()).await.unwrap(); }); tokio::spawn(async move { tx2.send("msg2".into()).await.unwrap(); }); while let Some(msg) = rx.recv().await { println!("{}", msg); } } // 2. oneshot - запрос/ответ паттерн async fn request_response() { let (resp_tx, resp_rx) = oneshot::channel::<u64>(); tokio::spawn(async move { resp_tx.send(42).unwrap(); }); let result = resp_rx.await.unwrap(); assert_eq!(result, 42); } // 3. broadcast - pub/sub: все подписчики получают все сообщения async fn broadcast_demo() { let (tx, _) = broadcast::channel::<u32>(16); let mut rx1 = tx.subscribe(); let mut rx2 = tx.subscribe(); tx.send(1).unwrap(); assert_eq!(rx1.recv().await.unwrap(), 1); assert_eq!(rx2.recv().await.unwrap(), 1); // оба получили! } // 4. watch - только последнее значение (config, health) async fn watch_demo() { let (tx, mut rx) = watch::channel(0u32); tx.send(1).unwrap(); tx.send(2).unwrap(); // 1 может быть потеряно - только последнее println!("{}", *rx.borrow()); // 2 rx.changed().await.unwrap(); // ждать следующего изменения }
35. Scoped threads: заимствование без 'static.
std::thread::scope (стабильно с Rust 1.63) разрешает потокам внутри scope заимствовать данные из стека вызывающего потока без Arc и без 'static. Ключевая гарантия: все потоки, порождённые внутри scope, гарантированно завершатся до выхода из scope – именно это позволяет borrow checker разрешать заимствования.
До 1.63 для этого требовался crossbeam::scope. Практические преимущества: нет накладных расходов Arc, нет lifetime-аннотаций на данных, нет клонирования. Паника одного потока внутри scope: scope ждёт все потоки и затем паникует в родительском. Возвращаемые значения доступны через ScopedJoinHandle::join().
Scoped threads – правильный инструмент для параллельных CPU-вычислений над фиксированным набором данных; rayon внутри тоже строится на scoped threads.
fn parallel_sum(data: &[i64]) -> i64 { let mid = data.len() / 2; let (left, right) = data.split_at(mid); std::thread::scope(|s| { // Заимствуем left и right напрямую - без Arc, без clone! let t1 = s.spawn(|| left.iter().sum::<i64>()); let t2 = s.spawn(|| right.iter().sum::<i64>()); // scope() ждёт оба потока, join() возвращает Result: t1.join().unwrap() + t2.join().unwrap() }) } // Мутабельное заимствование разных частей: fn parallel_fill(data: &mut [u8]) { let (left, right) = data.split_at_mut(data.len() / 2); std::thread::scope(|s| { s.spawn(|| { left.iter_mut().for_each(|b| *b = 1); }); s.spawn(|| { right.iter_mut().for_each(|b| *b = 2); }); // scope() гарантирует завершение до возврата }); } // До 1.63 требовался crossbeam: // crossbeam::thread::scope(|s| { s.spawn(|_| { /* ... */ }); }).unwrap(); let data: Vec<i64> = (1..=100).collect(); assert_eq!(parallel_sum(&data), 5050);
36. Memory ordering: четыре уровня и когда какой.
Relaxed – только атомарность операции: никаких гарантий порядка относительно других операций. Используйте для счётчиков, не синхронизирующих доступ к другим данным (Arc strong_count инкремент). Acquire – после операции: все последующие операции не могут быть перенесены до. Release – перед операцией: все предыдущие операции не могут быть перенесены после.
Пара Release+Acquire на одном атомике создаёт happens-before: всё, что было до Release-записи, видно после Acquire-чтения того же атомика. AcqRel – одновременно Acquire+Release: для RMW (например, compare_exchange). SeqCst – дополнительно гарантирует единый глобальный порядок всех SeqCst-операций – дорого (mfence на x86, sync на ARM).
Практика: Acquire/Release достаточно для 99% lock-free алгоритмов. SeqCst – когда необходим тотальный порядок между несколькими атомиками одновременно. Подвох: x86 имеет строгую TSO-модель (почти SeqCst даром), ARM – слабую. Алгоритм с Relaxed на x86 может работать случайно, но сломается на ARM. Для проверки ordering: loom (перебирает все interleaving-и потоков) или Miri (проверяет UB через Stacked Borrows, но не memory ordering – для ордеринга только loom). Оба инструмента нужны: Miri – для soundness unsafe, loom – для правильности lock-free.
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::sync::Arc; let flag = Arc::new(AtomicBool::new(false)); let data = Arc::new(AtomicU64::new(0)); // Producer: let (f, d) = (flag.clone(), data.clone()); std::thread::spawn(move || { d.store(42, Ordering::Relaxed); // запись данных f.store(true, Ordering::Release); // публикация: всё до Release видно }); // Consumer: while !flag.load(Ordering::Acquire) {} // когда увидим true, let v = data.load(Ordering::Relaxed); // запись 42 точно видна
37. Гонка данных vs race condition: путать опасно.
Это два разных понятия, и Rust предотвращает только одно из них. Гонка данных (data race) – конкретное UB: два потока одновременно обращаются к одной памяти, хотя бы один пишет, без синхронизации. Data race в C/C++/Rust без unsafe – неопределённое поведение: компилятор и процессор вправе делать что угодно.
Rust гарантирует: если код компилируется без unsafe, data race невозможна – это обеспечивает система типов через Send/Sync. Race condition – логическая ошибка: результат программы зависит от непредсказуемого порядка событий. Race condition возможна даже с правильной синхронизацией. Пример: поток A проверяет "если файл существует – удали", между check и delete поток B создаёт файл – B потеряет данные.
TOCTOU (time-of-check-time-of-use) – классический race condition. Atomic операции устраняют data race, но не race condition: атомарный check + атомарный modify – это всё ещё non-atomic пара. Для устранения race condition нужны транзакции, мьютексы или алгоритмы без состояния.
use std::sync::{Arc, Mutex}; use std::thread; // Data race (невозможно в safe Rust): // static mut COUNTER: i32 = 0; // thread::spawn(|| COUNTER += 1); // не компилируется без unsafe // Race condition (возможна даже с Mutex): fn race_condition_example() { let counter = Arc::new(Mutex::new(0)); // Оба потока: "если < 10, то инкремент" // Race condition: оба могут прочитать 9, оба инкрементируют до 11 let c1 = Arc::clone(&counter); let t1 = thread::spawn(move || { let mut guard = c1.lock().unwrap(); if *guard < 10 { // check *guard += 1; // use - атомарно с check благодаря Mutex } // этот пример ОК, т.к. весь check-use под одним локом }); // TOCTOU - классический race condition: // if Path::new("file.txt").exists() { // check // fs::remove_file("file.txt"); // use - другой поток может создать между ними // } t1.join().unwrap(); println!("counter: {}", *counter.lock().unwrap()); }
38. Deadlock: четыре условия и три способа лечения.
Классические четыре условия дедлока (Коффман): взаимное исключение, удержание-и-ожидание, отсутствие preemption, кольцевое ожидание. Достаточно убрать любое одно. На практике используют: единый порядок захвата локов везде в кодовой базе (наименьший адрес первым, или по статичной иерархии); минимизация критических секций; try_lock с таймаутом и откатом.
В async держать MutexGuard через await - почти гарантированный путь к дедлоку: задача может быть переведена на другой воркер с другим набором локов.
use tokio::sync::Mutex; use std::sync::Arc; let a = Arc::new(Mutex::new(0)); let b = Arc::new(Mutex::new(0)); // БАГ - смешанный порядок: async fn bad(a: Arc<Mutex<i32>>, b: Arc<Mutex<i32>>) { let _ga = a.lock().await; let _gb = b.lock().await; // другая таска: b -> a = deadlock } // Правильно - всегда в одном порядке (например, по адресу): async fn good(a: Arc<Mutex<i32>>, b: Arc<Mutex<i32>>) { let (first, second) = if Arc::as_ptr(&a) < Arc::as_ptr(&b) { (&a, &b) } else { (&b, &a) }; let _g1 = first.lock().await; let _g2 = second.lock().await; }
39-40. Rayon и work-stealing scheduler.
Rayon добавляет к Iterator параллельный аналог par_iter - и обычный последовательный код становится многопоточным. Внутри - work-stealing-планировщик с локальной очередью на каждый воркер: воркер берёт задачи с головы своей очереди (LIFO для локальности кеша), ворует с хвоста чужой (FIFO для справедливости). Это даёт автоматическую балансировку без центральной очереди.
Rayon отлично подходит для CPU-bound над коллекциями (map/filter/reduce), плох для IO-bound (нужен async) и для коротких задач с блокировками.
use rayon::prelude::*; let v: Vec<u64> = (0..1_000_000).collect(); // Последовательная сумма квадратов: let s1: u64 = v.iter().map(|x| x * x).sum(); // Параллельная - просто par_iter(): let s2: u64 = v.par_iter().map(|x| x * x).sum(); assert_eq!(s1, s2); // На 8 ядрах ускорение обычно x6-x7 (не x8 из-за оверхеда work-stealing)
41. Thread pool: почему spawn потока – дорого.
Создание потока ОС – это syscall: clone() (Linux) / CreateThread (Windows), выделение стека (по умолчанию 8МБ адресного пространства на Linux, 2МБ физически на первых страницах), регистрация в планировщике ядра. Стоит 30–100 мкс. Это неприемлемо для коротких задач в горячем цикле. Thread pool переиспользует уже созданные потоки: задача отправляется в очередь, уже ждущий поток её подхватывает – ~100нс.
Основные варианты: rayon::ThreadPool для CPU-bound параллелизма (work-stealing, идеален для iter/par_iter), tokio runtime для async IO-bound, threadpool crate для простого fixed-size пула, tokio::task::spawn_blocking для блокирующих вызовов внутри async (отдельный пул с лимитом 512 потоков по умолчанию (конфигурируется через Builder::max_blocking_threads) – не для постоянной работы).
Важно: размер пула CPU-bound = число логических ядер. IO-bound пул может быть больше (ждут IO). rayon::ThreadPoolBuilder::new().num_threads(n).build() – явная конфигурация.
// БАГ: spawn нового потока на каждую задачу fn bad_many_tasks(tasks: Vec<u64>) { for task in tasks { std::thread::spawn(move || { /* работа: 30-100мкс накладных */ }); // 1000 задач = 30-100мс только на создание потоков! } } // Правильно: rayon для CPU-bound параллелизма use rayon::prelude::*; fn good_rayon(data: &[u64]) -> u64 { data.par_iter().map(|&x| x * x).sum() // Внутри: work-stealing пул = число CPU ядер } // tokio::task::spawn_blocking для блокирующего кода в async: async fn process_file(path: String) -> std::io::Result<Vec<u8>> { // НЕ ДЕЛАТЬ: std::fs::read блокирует tokio worker // let data = std::fs::read(&path)?; // BAD // ПРАВИЛЬНО: отдать в blocking пул tokio::task::spawn_blocking(move || { std::fs::read(&path) // блокирующий IO в отдельном потоке }).await.unwrap() } // Кастомный rayon пул для изоляции: fn isolated_compute(data: Vec<i32>) -> i32 { let pool = rayon::ThreadPoolBuilder::new() .num_threads(4) .build() .unwrap(); pool.install(|| data.par_iter().sum()) // пул не делит потоки с глобальным rayon пулом }
42. Barrier: фазовая синхронизация.
Barrier – примитив для синхронизации нескольких потоков в одной точке: все потоки блокируются на barrier.wait() и освобождаются только тогда, когда ожидаемое количество потоков вызвало wait(). Это "сборная точка" (rendezvous point) или "барьер фазы". Один из потоков получает BarrierWaitResult с is_leader() == true – это полезно для выполнения однократной работы между фазами (например, агрегация результатов).
Barrier в std многоразовый: после того как все потоки прошли барьер, он автоматически сбрасывается для следующей фазы. Типичный паттерн: параллельное вычисление в несколько фаз, где каждая фаза зависит от результатов предыдущей. В async-коде аналог – tokio::sync::Barrier. Отличие от join: join ждёт завершения потоков, barrier позволяет потокам продолжать работу после точки синхронизации.
use std::sync::{Arc, Barrier}; use std::thread; fn main() { let num_threads = 4; let barrier = Arc::new(Barrier::new(num_threads)); let mut handles = vec![]; for i in 0..num_threads { let b = Arc::clone(&barrier); handles.push(thread::spawn(move || { // Фаза 1: каждый поток делает свою работу println!("поток {} завершил фазу 1", i); thread::sleep(std::time::Duration::from_millis(i as u64 * 10)); // Все ждут здесь, пока все не придут let result = b.wait(); // Лидер выполняет однократную работу if result.is_leader() { println!("лидер: все завершили фазу 1, начинаем фазу 2"); } // Фаза 2: все потоки продолжают println!("поток {} в фазе 2", i); })); } for h in handles { h.join().unwrap(); } }
43. Condvar: ждать условие, а не время.
Condvar (переменная условия) – примитив для ожидания, пока некоторое условие не станет истинным, без активного опроса (busy-wait). Работает в паре с Mutex: поток берёт лок, проверяет условие, если ложно – атомарно отпускает лок и засыпает на condvar.wait(). При пробуждении лок снова захватывается автоматически.
Продьюсер будит спящих через notify_one() (один поток) или notify_all() (все). Критически важно всегда проверять условие в цикле while !ready, а не if !ready – spurious wakeup (ложное пробуждение) реальны на POSIX-системах: поток может проснуться без notify.
wait_while и wait_timeout_while делают этот цикл за вас. Condvar эффективнее sleep-лупа: вместо периодических пробуждений поток спит ровно до нужного момента, снижая CPU и latency.
use std::sync::{Arc, Condvar, Mutex}; use std::thread; fn main() { let pair = Arc::new((Mutex::new(false), Condvar::new())); let pair2 = Arc::clone(&pair); // Рабочий поток ждёт сигнала let worker = thread::spawn(move || { let (lock, cvar) = &*pair2; let mut ready = lock.lock().unwrap(); // wait_while атомически отпускает лок и ждёт // автоматически цикличен - защита от spurious wakeup ready = cvar.wait_while(ready, |r| !*r).unwrap(); println!("worker: сигнал получен, ready={}", *ready); }); thread::sleep(std::time::Duration::from_millis(100)); // Продьюсер отправляет сигнал let (lock, cvar) = &*pair; *lock.lock().unwrap() = true; cvar.notify_one(); // будит один ожидающий поток worker.join().unwrap(); }
44. Spinlock: когда крутиться выгоднее, чем парковаться.
Spinlock не отдаёт поток ОС при ожидании: он крутится в цикле, атомически проверяя флаг. Преимущество перед Mutex (который делает системный вызов): если ожидаемое время удержания лока очень мало (десятки наносекунд), стоимость системного вызова park/unpark (microseconds) перевешивает стоимость активного ожидания.
Спинлок выигрывает при: 1) низкой contention, 2) очень коротких критических секциях, 3) запрете на блокировку потока (interrupt handlers, real-time). Проигрывает при высокой contention и длинных секциях – поток тратит CPU впустую. В Rust нет spinlock в std; крейт spin предоставляет spin::Mutex.
Важно: core::hint::spin_loop() внутри цикла ожидания – не просто nop, это инструкция PAUSE на x86 (снижает энергопотребление и позволяет hyperthreading работать эффективнее) или WFE/YIELD на ARM.
use std::sync::atomic::{AtomicBool, Ordering}; use std::hint; // Простой spinlock на атомике struct SpinLock { locked: AtomicBool, } impl SpinLock { const fn new() -> Self { SpinLock { locked: AtomicBool::new(false) } } fn lock(&self) { // compare_exchange_weak: может ложно упасть, зато быстрее на LL/SC while self.locked .compare_exchange_weak(false, true, Ordering::Acquire, Ordering::Relaxed) .is_err() { // Пока лок занят - крутимся; PAUSE/WFE инструкция while self.locked.load(Ordering::Relaxed) { hint::spin_loop(); } } } fn unlock(&self) { self.locked.store(false, Ordering::Release); } } static LOCK: SpinLock = SpinLock::new(); fn main() { LOCK.lock(); // критическая секция println!("locked"); LOCK.unlock(); }
45. Thread local: своё значение в каждом потоке.
thread_local! макрос создаёт LocalKey - тип, дающий каждому потоку собственный экземпляр. Внутри один поток обращается через with(|v| ...). Не Send (никто не передаёт это между потоками - это и есть смысл). Типичные применения: per-thread аллокаторы (jemalloc, mimalloc), per-thread кеши форматтеров, trace-context для observability, RNG (thread_rng в rand). Стоимость доступа на x86_64 Linux - один FS-сегментный регистр + offset, около 1нс.
use std::cell::RefCell; thread_local! { static COUNTER: RefCell<u64> = RefCell::new(0); } fn increment() { COUNTER.with(|c| *c.borrow_mut() += 1); } fn get() -> u64 { COUNTER.with(|c| *c.borrow()) } // Каждый поток видит свой COUNTER, синхронизация не нужна вообще
Грабли блока на собеседовании.
Rc в tokio::spawn - не Send, не скомпилится (это правильно). Захват локов в разном порядке в разных функциях - дедлок в проде через неделю. RwLock на критсекции в 100нс - проиграет Mutex по тейл-латенси. Ordering::Relaxed везде "потому что быстро" - без Acquire/Release видимость записей не гарантирована, lock-free алгоритм будет работать на x86 (там сильная модель), но сломается на ARM. Удержание std::sync::Mutex через .await - блокирует tokio worker и убивает throughput.
// Граблина: Object safety - метод с generic trait MyTrait { fn process<T>(&self, t: T); // нарушает object safety } // let _: &dyn MyTrait; // ОШИБКА: trait cannot be made into object // Правильно: убрать generic или добавить where Self: Sized trait FixedTrait { fn process_str(&self, s: &str); // конкретный тип - OK fn process<T>(&self, t: T) where Self: Sized {} // excluded from vtable } // Граблина: blanket impl конфликт // impl<T: Display> MyTrait for T {} // из вашего крейта // impl MyTrait for i32 {} // конфликт с blanket impl! // Граблина: забытый where-bound в методе struct Wrapper<T>(T); impl<T> Wrapper<T> { fn show(&self) where T: std::fmt::Display { println!("{}", self.0); } // Без where T: Display - не компилируется если нет bound на impl }
Блок 4: async и runtime (46-62)
Async-блок - то, на чём валится 80% middle-кандидатов. Не понимают, что Future делает только когда его polлят, и держат MutexGuard через .await. Если вы делаете tokio в проде - это самый честный фильтр.
46-47. async fn и Future: что компилятор делает руками программиста.
async fn – синтаксический сахар: компилятор разворачивает async fn foo() -> T в fn foo() -> impl Future<Output = T>. Внутри – анонимная enum-структура (стейт-машина): каждая .await-точка – отдельное состояние enum; локальные переменные между ними – поля. Размер стейт-машины = сумма размеров "толстейших" путей по ветвлению CFG, не сумма всех переменных.
Future ленивный и пул-базированный: ничего не делает без poll() – это ключевое отличие от JS Promise (стартует сразу при создании). .await = поллинг до Ready. Полл возвращает Pending: future зарегистрировал Waker и будет разбужен, когда внешнее событие будет готово. tokio::spawn(future) отправляет future в executor и немедленно начинает его исполнение; без spawn future ленив – ничего не делает.
Future + Send: если в анонимной стейт-машине есть хоть одно поле !Send (например, Rc или MutexGuard), весь future !Send – нельзя в tokio::spawn, нужен LocalSet. Стейт-машина обычно !Unpin: нельзя перемещать после начала pollа, иначе self-referential поинтеры сломаются.
// async fn: async fn fetch(url: String) -> String { /* ... */ url } // Эквивалентно вручную: fn fetch_manual(url: String) -> impl std::future::Future<Output = String> { async move { url } } // Future без await ничего не делает: let _f = fetch("x".into()); // НЕ выполняется! // Чтобы запустилось - либо .await, либо tokio::spawn // Poll-модель: use std::task::{Context, Poll}; use std::pin::Pin; fn manual_poll<F: std::future::Future>(mut f: Pin<&mut F>, cx: &mut Context) { match f.as_mut().poll(cx) { Poll::Ready(_v) => { /* готово */ } Poll::Pending => { /* future зарегистрировал Waker, спит */ } } }
48. tokio vs async-std vs smol: что выбрать в 2026.
Tokio – де-факто стандарт async-экосистемы: самая богатая экосистема (axum, hyper, tonic, tower, reqwest), многопоточный work-stealing планировщик, встроенные примитивы tokio::fs, tokio::net, tokio::sync, tokio::time. Подавляющее большинство async-крейтов написаны и тестированы именно с tokio.
async-std задумывался как async-зеркало стандартной библиотеки с похожим API, но в 2026 году разработка фактически заморожена – экосистема и обновления сильно отстают, новые проекты не стоит начинать на нём. smol – минималистичный runtime: core executor весит единицы килобайт, легко встраивается в существующий event loop, подходит для CLI-утилит, плагинов, WebAssembly где важен размер бинаря.
embassy – специализированный async-рантайм для микроконтроллеров, no-std, bare-metal. В 2026 выбор прост: tokio для любых серверных и сетевых приложений, smol когда критичны зависимости и размер, embassy для embedded-Rust. Runtime несовместимы между собой: нельзя использовать tokio::TcpStream в smol-рантайме и наоборот – проверяйте зависимости крейтов.
# Cargo.toml - выбор рантайма # Tokio (рекомендовано для большинства случаев) [dependencies] tokio = { version = "1", features = ["full"] } # Smol (минимальный) # smol = "2" # ---- Tokio ---- #[tokio::main] async fn main() { tokio::time::sleep(std::time::Duration::from_millis(1)).await; let handle = tokio::spawn(async { "spawned task" }); println!("{}", handle.await.unwrap()); } // ---- smol ---- // fn main() { // smol::block_on(async { // println!("smol runtime"); // }); // } // Проверить runtime крейта: ищите в Cargo.toml или документации // tokio = { ... } в зависимостях крейта = только tokio
49. Executor и reactor: разделение труда внутри tokio.
Асинхронный рантайм состоит из двух компонентов с разными задачами. Executor (исполнитель) занимается планированием: хранит очередь готовых задач, вызывает poll() на future когда она разбужена, управляет work-stealing между потоками. В tokio это multi-thread планировщик: пул потоков (по умолчанию = число CPU) с очередями per-thread и steal-механизмом.
Reactor (реактор) занимается I/O: регистрирует файловые дескрипторы в OS (epoll на Linux, kqueue на macOS, IOCP на Windows), блокируется в ожидании событий, будит нужные задачи через Waker. В tokio reactor работает на выделенном потоке или интегрирован в поток executor-а. Разделение позволяет executor-у не делать syscall-ы при каждом poll: он просто опрашивает ready-очередь.
Пользовательский код никогда не взаимодействует с reactor напрямую – это детали реализации. Один tokio runtime может иметь один reactor на всё приложение, что эффективнее чем per-thread event loop.
// Визуализация взаимодействия executor + reactor: // // Task (poll) → needs I/O → регистрирует fd в reactor // ↓ // epoll_wait() в reactor-thread // ↓ event ready // Waker::wake() → задача в ready-очередь executor // ↓ // executor.poll(task) → task продолжается use tokio::net::TcpListener; #[tokio::main] // создаёт runtime с executor + reactor async fn main() -> std::io::Result<()> { let listener = TcpListener::bind("127.0.0.1:0").await?; let addr = listener.local_addr()?; println!("listening on {}", addr); // accept() регистрирует socket в reactor (epoll/kqueue) // возвращает Poll::Pending и ждёт события от OS // при входящем соединении reactor будит эту задачу tokio::time::timeout( std::time::Duration::from_millis(1), listener.accept() ).await.ok(); Ok(()) }
50. Кооперативная многозадачность: почему tight loop убивает runtime.
tokio (как и все async-рантаймы Rust) – кооперативный планировщик. Воркер исполняет задачу до её следующего .await или до завершения – пока задача не вернёт Pending, никто другой на этом воркере не исполняется. CPU-bound цикл без .await блокирует воркер целиком: все задачи в очереди этого воркера ждут, latency растёт, таймеры не срабатывают.
work-stealing перенесёт задачу на другой воркер, но CPU-bound задача всё равно занимает воркер. Решения: (1) tokio::task::yield_now().await в горячем цикле возвращает Pending и даёт другим задачам шанс, но часто (1 раз в N итераций) должно оставаться цель вычисления; (2) tokio::task::spawn_blocking(предаёт задачу в blocking thread pool – отдельные потоки, не занимают async-воркеры); (3) rayon::spawn/par_iter для параллельных CPU-bound операций.
Важно: spawn_blocking имеет лимит 512 потоков по умолчанию (конфигурируется через Builder::max_blocking_threads) – не для постоянной CPU-работы.
// БАГ - блокирует worker: async fn bad_cpu() { let mut sum = 0u64; for i in 0..1_000_000_000 { sum = sum.wrapping_add(i); } println!("{}", sum); } // Лекарство 1 - yield: async fn good_yield() { let mut sum = 0u64; for i in 0..1_000_000_000 { sum = sum.wrapping_add(i); if i % 10_000 == 0 { tokio::task::yield_now().await; } } } // Лекарство 2 - spawn_blocking для тяжёлой CPU-работы: let result = tokio::task::spawn_blocking(|| heavy_compute()).await.unwrap();
51-52. tokio::select! и cancel safety: тихая катастрофа.
select! гоняет несколько future параллельно на одном таске и возвращает результат первого готового. Все остальные future в этот момент дропаются - и это убивает не cancel-safe операции. cancel-safe значит "если задачу убили посреди исполнения, никакие данные не потерялись". Read из канала, recv из mpsc - cancel-safe. Запись по половине, RMW в Mutex через await - НЕ cancel-safe: данные могли быть прочитаны из источника, но не доставлены в место назначения.
use tokio::sync::mpsc; use tokio::time::{sleep, Duration}; let (tx, mut rx) = mpsc::channel::<u32>(10); // БАГ: read из стрима НЕ cancel-safe, при отмене байты теряются async fn bad(mut reader: tokio::io::DuplexStream) { let mut buf = vec![0u8; 1024]; tokio::select! { _ = sleep(Duration::from_secs(1)) => {} // тут отменяется read! _ = tokio::io::AsyncReadExt::read(&mut reader, &mut buf) => {} } // прочитанные байты потеряны - они в драфт-буфере future, который дропнули } // OK: recv() из mpsc cancel-safe by design tokio::select! { Some(v) = rx.recv() => println!("got {}", v), _ = sleep(Duration::from_secs(5)) => println!("timeout"), }
53. spawn и join: как улетают и возвращаются задачи.
tokio::spawn(future) отправляет future в очередь executor-а как новую задачу. Возвращает JoinHandle<T> – future, который завершается когда задача завершается. Drop JoinHandle не отменяет задачу: она продолжает работать в фоне (detach). Чтобы явно отменить – вызовите handle.abort().
Задача должна быть Send + 'static для multi-thread runtime, так как может мигрировать между потоками. Для ожидания нескольких задач есть tokio::join!(a, b, c) – параллельное ожидание фиксированного набора futures на одной задаче; tokio::try_join! – то же самое, но прерывается при первой ошибке.
JoinSet – динамическая коллекция задач: добавляйте задачи через set.spawn() и получайте результаты по мере завершения через set.join_next().await. Паника внутри spawn-нутой задачи не убивает программу: она перехватывается и превращается в Err(JoinError::panic(...)) на JoinHandle.
use tokio::task::JoinSet; #[tokio::main] async fn main() { // join! - параллельное ожидание, оба работают одновременно let (a, b) = tokio::join!( async { 1_u32 + 1 }, async { 2_u32 * 2 } ); println!("join: {} {}", a, b); // JoinSet - динамическое множество задач let mut set = JoinSet::new(); for i in 0..5 { set.spawn(async move { tokio::time::sleep(std::time::Duration::from_millis(10 * i)).await; i * i }); } while let Some(result) = set.join_next().await { println!("результат: {}", result.unwrap()); // порядок произвольный } // abort - отмена задачи let handle = tokio::spawn(async { tokio::time::sleep(std::time::Duration::from_secs(10)).await; }); handle.abort(); // задача отменена }
54. JoinSet: правильный способ дождаться группу.
JoinSet – коллекция задач, объединённых по жизненному циклу. join_next().await возвращает результат первой завершившейся задачи (не ждёт конкретную). При drop JoinSet все задачи автоматически отменяются (abort) – это structured concurrency в миниатюре. Плохой паттерн: Vec<JoinHandle> + цикл .await по каждому – блокируется на самой медленной задаче, теряет возможность реагировать на быстрые, и при панике одной задачи остальные продолжают работать.
abort_all() – явная отмена всех без drop. spawn_on – запуск задачи на конкретном runtime (для multi-runtime архитектур). JoinSet отлично подходит для: worker pool с bounded concurrency (spawn N задач, при завершении каждой добавлять новую), обработка в порядке завершения, graceful shutdown с таймаутом. Связан с tokio_util::task::TaskTracker для более тонкого контроля lifecycle.
use tokio::task::JoinSet; use tokio::time::{sleep, Duration}; // Правильно: JoinSet, обрабатываем в порядке завершения async fn process_all(items: Vec<u32>) -> Vec<u32> { let mut set = JoinSet::new(); let mut results = Vec::new(); for item in items { set.spawn(async move { // разное время выполнения - быстрые завершатся первыми sleep(Duration::from_millis(item as u64 % 100)).await; item * 2 }); } // join_next возвращает первую завершившуюся, не ждёт конкретную: while let Some(result) = set.join_next().await { results.push(result.unwrap()); } results } // Bounded concurrency: не более N задач одновременно async fn bounded_concurrency(urls: Vec<String>, max_concurrent: usize) { let mut set = JoinSet::new(); let mut iter = urls.into_iter(); // Заполняем до max_concurrent for url in iter.by_ref().take(max_concurrent) { set.spawn(async move { fetch(url).await }); } // По мере завершения добавляем новые while let Some(_result) = set.join_next().await { if let Some(url) = iter.next() { set.spawn(async move { fetch(url).await }); } } } async fn fetch(url: String) -> String { url } // заглушка // Graceful shutdown с таймаутом: async fn shutdown_with_timeout(mut set: JoinSet<()>) { let deadline = sleep(Duration::from_secs(5)); tokio::pin!(deadline); loop { tokio::select! { _ = &mut deadline => { set.abort_all(); break; } res = set.join_next() => { if res.is_none() { break; } } } } }
55. async trait: что наконец починили в 1.75 и какие грабли остались.
До Rust 1.75 async fn в трейтах требовал внешнего крейта async-trait, который через proc-macro оборачивал каждый async-метод в Box<dyn Future> – добавляя heap-аллокацию и стирание типа на каждый вызов. С Rust 1.75 стабилизировали Return-Position impl Trait in Traits (RPITIT): теперь async fn в трейтах работает нативно через impl Future.
Но остаются грабли: нативный async trait не object-safe – нельзя использовать как dyn MyAsyncTrait, потому что возвращаемый impl Future имеет разный тип для каждой реализации. Если нужен dyn – по-прежнему нужен async-trait или ручной BoxFuture. Второй граб: Send-ность возвращаемого Future зависит от тела метода и может быть неожиданно не-Send. В таких случаях используют #[trait_variant::make(MyTraitSend: Send)] для генерации Send-версии трейта.
// С Rust 1.75+ - нативная поддержка без async-trait trait Fetcher { async fn fetch(&self, url: &str) -> Result<String, String>; } struct HttpFetcher; impl Fetcher for HttpFetcher { async fn fetch(&self, url: &str) -> Result<String, String> { // реальный HTTP-запрос Ok(format!("response from {}", url)) } } // НЕ компилируется - async trait не object-safe: // fn get_fetcher() -> Box<dyn Fetcher> { ... } // Для dyn - используем trait_variant или async-trait крейт use async_trait::async_trait; #[async_trait] trait DynFetcher { async fn fetch(&self, url: &str) -> Result<String, String>; } fn make_fetcher() -> Box<dyn DynFetcher + Send + Sync> { Box::new(HttpFetcher) }
56. Backpressure: то, чего нет в неограниченном канале.
Backpressure (обратное давление) – механизм, при котором быстрый продьюсер замедляется, когда медленный консьюмер не успевает обрабатывать данные. Без backpressure: буфер растёт неограниченно → OOM или потеря данных. mpsc::channel(n) с bounded capacity – встроенный backpressure: tx.send(msg).await приостанавливает продьюсер когда буфер заполнен.
mpsc::unbounded_channel() backpressure не имеет: try_send никогда не блокирует, буфер растёт до OOM – не используйте для неограниченного потока данных. try_send на bounded канале немедленно возвращает Err(TrySendError::Full) без ожидания – для реализации сброса или альтернативных путей.
В HTTP/2 и gRPC backpressure встроен через flow control: получатель объявляет, сколько байт он готов принять. tokio::sync::Semaphore – удобный инструмент backpressure для ограничения параллельности без канала: let permit = semaphore.acquire().await – ожидает, если уже N задач активны. Tower middleware buffer и concurrency_limit добавляют backpressure на уровне сервисов.
use tokio::sync::mpsc; #[tokio::main] async fn main() { // Bounded: backpressure встроен - буфер = 10 let (tx, mut rx) = mpsc::channel::<u64>(10); let producer = tokio::spawn(async move { for i in 0..100 { // Если буфер полон - ждёт, замедляя продьюсер tx.send(i).await.unwrap(); println!("отправил {}", i); } }); let consumer = tokio::spawn(async move { while let Some(v) = rx.recv().await { // Медленный консьюмер tokio::time::sleep(std::time::Duration::from_millis(10)).await; println!("обработал {}", v); } }); let _ = tokio::join!(producer, consumer); // Продьюсер ждёт когда буфер освобождается - нет OOM }
57. Streams: асинхронный итератор.
Stream из futures-rs – асинхронный аналог Iterator: trait с методом poll_next(cx) -> Poll<Option<T>>. Ленивый, как и Future: ничего не делает без poll. futures::StreamExt добавляет адапторы: map, filter, flat_map, take, skip, collect, chunks, throttle, buffered, buffer_unordered.
tokio-stream предоставляет интеграцию с tokio. Отличия от Iterator: элементы могут приходить с произвольной задержкой (сокет, база данных, Kafka consumer), размер заранее неизвестен, бесконечные стримы нормальны. buffered(N) и buffer_unordered(N) – ключевые адапторы: buffer_unordered обрабатывает N futures параллельно и возвращает результаты в порядке завершения (не в порядке запуска) – это идеально для параллельных HTTP-запросов.
В Rust 2024 async for (async iterator) в процессе стабилизации. Стримы хорошо компонуются: stream::iter + flat_map + buffered – типичная цепочка для батч-обработки.
use futures::stream::{self, StreamExt}; use tokio::time::{sleep, Duration}; // Базовый stream из итератора: async fn basic_stream() { let s = stream::iter(vec![1u32, 2, 3, 4, 5]); let doubled: Vec<u32> = s.map(|x| x * 2).collect().await; assert_eq!(doubled, vec![2, 4, 6, 8, 10]); } // buffered - N futures параллельно, результаты в порядке ЗАПУСКА: async fn parallel_ordered(urls: Vec<String>) -> Vec<usize> { stream::iter(urls) .map(|url| async move { // имитируем HTTP-запрос sleep(Duration::from_millis(10)).await; url.len() }) .buffered(8) // не более 8 одновременно, порядок сохраняется .collect() .await } // buffer_unordered - N futures параллельно, результаты в порядке ЗАВЕРШЕНИЯ: async fn parallel_unordered(ids: Vec<u32>) { stream::iter(ids) .map(|id| async move { sleep(Duration::from_millis(id as u64 % 50)).await; id }) .buffer_unordered(16) // быстрые завершаются первыми .for_each(|id| async move { println!("done: {}", id) }) .await; } // Бесконечный stream с throttle: async fn ticker() { use tokio_stream::wrappers::IntervalStream; let mut ticker = IntervalStream::new(tokio::time::interval(Duration::from_secs(1))); let mut count = 0; while let Some(_) = ticker.next().await { println!("tick {}", count); count += 1; if count >= 5 { break; } } }
58. pin_project: как правильно сделать self-referential future.
Pin<&mut T> гарантирует, что T не будет перемещён в памяти после пиннирования – это критично для самореференсных структур, где поля указывают на другие поля той же структуры. Проблема: если у Future есть поле buf: Vec<u8> и поле ptr: *const u8 указывающее в buf, перемещение структуры делает ptr dangling.
pin_project – процедурный макрос решающий boilerplate-проблему: автоматически генерирует проекцию project() возвращающую структуру с полями, обёрнутыми в Pin (для полей #[pin]) или просто &mut (для обычных полей). Без него нужно вручную писать unsafe с обоснованием.
Альтернатива: pin-project-lite – декларативный macro_rules! вариант без proc-macro, меньше зависимостей. В большинстве случаев писать кастомные Future вручную не нужно – компилятор генерирует корректный pinned state machine из async fn.
use pin_project::pin_project; use std::pin::Pin; use std::future::Future; use std::task::{Context, Poll}; #[pin_project] struct MapFuture<F, G> { #[pin] inner: F, f: Option<G>, } impl<F, G, T, U> Future for MapFuture<F, G> where F: Future<Output = T>, G: FnOnce(T) -> U, { type Output = U; fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> { let this = self.project(); // безопасная проекция // this.inner: Pin<&mut F> // this.f: &mut Option<G> match this.inner.poll(cx) { Poll::Ready(val) => { let f = this.f.take().unwrap(); Poll::Ready(f(val)) } Poll::Pending => Poll::Pending, } } }
59. Executor budget: невидимая защита от голодания.
Tokio защищает от голодания задач через механизм budget (бюджет). Каждой задаче при запуске выдаётся бюджет в 128 единиц. Каждый вызов операции ввода-вывода или ресурса tokio (например, чтение из TcpStream, получение из mpsc::channel) тратит одну единицу. Когда бюджет исчерпан, следующая такая операция возвращает Poll::Pending принудительно – даже если данные готовы – что вызывает yield задачи и переключение на другую.
Это предотвращает ситуацию, когда одна задача с большим потоком данных монополизирует поток исполнения. Важно: tokio::task::yield_now().await – явный yield без бюджетной механики, даёт другим задачам шанс поработать. coop::proceed() – внутренний API для кастомных примитивов tokio. Если вы пишете кастомный futures-примитив поверх tokio и хотите участвовать в бюджете – используйте tokio::task::coop.
use tokio::sync::mpsc; #[tokio::main] async fn main() { let (tx, mut rx) = mpsc::channel::<i32>(1000); // Заполним канал for i in 0..1000 { tx.try_send(i).ok(); } // Без бюджета эта задача обработала бы все 1000 сообщений // без yield. С бюджетом tokio заставит её уступить после ~128. let consumer = tokio::spawn(async move { let mut count = 0; while let Some(v) = rx.recv().await { // тратит 1 бюджет count += 1; // После ~128 recv tokio принудительно уступит другим задачам } count }); // Явный yield - уступить CPU другим задачам tokio::task::yield_now().await; println!("другие задачи получили шанс поработать"); let _ = consumer.await; }
60. LocalSet: для !Send-future.
tokio::task::LocalSet – контекст выполнения, в котором все задачи запускаются на одном потоке и не требуют Send. Это позволяет использовать Rc, RefCell, thread_local! и любые другие !Send типы внутри async-кода. tokio::task::spawn_local работает только внутри LocalSet. LocalSet::run_until запускает set до завершения переданного future; LocalSet::block_on – блокирующий вариант для не-async контекста.
Типичные use-cases: WASM (нет потоков, всё single-threaded), GUI приложения с async (egui + tokio), legacy-код с !Send глобальным состоянием, тестирование кода с thread_local. Важно: LocalSet работает только с current_thread runtime или в spawn_blocking; с multi_thread runtime LocalSet нужно явно привязать к одному потоку.
!Send future всегда требует LocalSet или current_thread runtime – попытка tokio::spawn !Send future = E0277.
use tokio::task::LocalSet; use std::rc::Rc; use std::cell::RefCell; // Rc и RefCell - !Send, нельзя в обычный tokio::spawn async fn local_task_demo() { let local = LocalSet::new(); // Состояние с Rc - !Send let state = Rc::new(RefCell::new(vec![1u32, 2, 3])); local.run_until(async move { let s1 = state.clone(); let s2 = state.clone(); tokio::task::spawn_local(async move { s1.borrow_mut().push(4); // Rc и RefCell работают - мы на одном потоке }); tokio::task::spawn_local(async move { tokio::time::sleep(std::time::Duration::from_millis(1)).await; println!("{:?}", s2.borrow()); // [1, 2, 3, 4] }); }).await; } // С multi_thread runtime - LocalSet нужно явно запустить на нужном потоке: fn spawn_local_on_dedicated_thread<F>(f: F) where F: std::future::Future + 'static, { std::thread::spawn(move || { let rt = tokio::runtime::Builder::new_current_thread() .enable_all() .build() .unwrap(); let local = LocalSet::new(); local.block_on(&rt, f); }); } // WASM пример - там всегда single-threaded: // #[wasm_bindgen] // pub async fn wasm_main() { // let local = LocalSet::new(); // local.run_until(async { /* !Send код работает в WASM */ }).await; // }
61. Timeout: тонкие края.
tokio::time::timeout(duration, future).await оборачивает future и возвращает Err(Elapsed), если future не завершилась за указанное время. Тонкие грабли: 1) отмена при таймауте не гарантирует прекращение работы: если future запустила tokio::spawn, spawned-задача продолжает работать независимо.
Таймаут отменяет только polling текущей future, не spawned-задачи. 2) Точность таймера: tokio использует granularity 1ms по умолчанию на большинстве платформ – не годится для суб-миллисекундных таймаутов. 3) select! + timeout: используйте tokio::select! с tokio::time::sleep как альтернативную ветку для более гибкого контроля.
4) Deadline vs duration: Instant::now() + duration как deadline дает более предсказуемое поведение при множественных операций с общим лимитом времени. timeout_at(instant, future) принимает абсолютное время.
use tokio::time::{timeout, timeout_at, Duration, Instant}; async fn slow_operation() -> String { tokio::time::sleep(Duration::from_millis(200)).await; "done".to_string() } #[tokio::main] async fn main() { // Базовый timeout match timeout(Duration::from_millis(100), slow_operation()).await { Ok(result) => println!("успех: {}", result), Err(_) => println!("таймаут истёк"), } // timeout_at - дедлайн для цепочки операций let deadline = Instant::now() + Duration::from_millis(500); let _ = timeout_at(deadline, slow_operation()).await; // Оставшееся время дедлайна используется для второй операции let _ = timeout_at(deadline, slow_operation()).await; // select! с таймаутом - более гибкий контроль tokio::select! { result = slow_operation() => println!("результат: {}", result), _ = tokio::time::sleep(Duration::from_millis(150)) => { println!("таймаут через select!"); } } }
62. Структурированная конкурентность: куда индустрия движется.
Структурированная конкурентность - идея, что любые fork-нутые задачи завершаются до выхода из родительского скоупа. В Rust это реализуется через JoinSet и CancellationToken (из tokio-util). При drop JoinSet все его задачи aborted - это даёт чистый shutdown без зависших таck-ок. CancellationToken даёт иерархическую отмену: родительский токен отменяет все дочерние. Это паттерн, который заменяет ad-hoc Arc<AtomicBool> флаги "пора закрываться".
use tokio_util::sync::CancellationToken; use tokio::task::JoinSet; let root = CancellationToken::new(); let mut set = JoinSet::new(); for i in 0..3 { let child = root.child_token(); set.spawn(async move { tokio::select! { _ = child.cancelled() => println!("worker {} cancelled", i), _ = tokio::time::sleep(std::time::Duration::from_secs(60)) => {} } }); } // Где-то ловим сигнал: tokio::signal::ctrl_c().await.unwrap(); root.cancel(); // все дочерние получат cancelled while let Some(_) = set.join_next().await {}
Грабли блока на собеседовании.
std::sync::Mutex через .await - блокирует worker, убивает throughput. Vec<JoinHandle> вместо JoinSet - блок на медленной задаче. Не cancel-safe read в select! - потеря данных. Долгий CPU-цикл без yield_now - стагнация runtime. spawn в горячем цикле с unbounded mpsc - OOM. async fn в трейте без + Send - не работает на multi-thread runtime. Rc в spawn (не Send) - E0277, нужен LocalSet или Arc.
// Граблина: std::Mutex через .await - блокирует весь поток use std::sync::Mutex; // ПЛОХО: std::Mutex через await-boundary - deadlock или panic async fn bad_mutex() { let m = Mutex::new(0); let guard = m.lock().unwrap(); tokio::time::sleep(std::time::Duration::from_millis(1)).await; // блокирует поток! // guard dropped here } // ХОРОШО: tokio::Mutex или явный drop до await use tokio::sync::Mutex as AsyncMutex; async fn good_mutex() { let m = AsyncMutex::new(0); { let mut guard = m.lock().await; // async-aware lock *guard += 1; } // guard dropped before await tokio::time::sleep(std::time::Duration::from_millis(1)).await; // OK } // Граблина: забытый .await async fn fetch() -> String { "data".to_string() } // let result = fetch(); // тип Future<Output=String>, не String! // let result = fetch().await; // правильно
Блок 5: unsafe, FFI, низкий уровень (63-78)
Здесь главный фильтр на staff. Middle путает unsafe с "выключателем правил", senior знает четыре суперспособности и где UB. Без unsafe не написать ни Vec, ни Mutex, ни tokio - так что отказ от него на проде это миф.
63-64. Что разрешает unsafe и что НЕ отключает.
unsafe даёт ровно пять суперспособностей: (1) разыменование сырого указателя (*const T / *mut T); (2) вызов unsafe-функции или метода; (3) чтение/запись mutable static; (4) реализация unsafe trait; (5) доступ к полю union. Всё! Мнемоника: «Дереф сырой указатель, Вызови unsafe-функцию, Пиши mutable static, Имплементируй unsafe trait, Читай union» (ДВПИЧ – 5 пунктов).
Чёткий хирургический ключ к памяти, a не общий байпасс системы типов. unsafe НЕ отключает: borrow checker для обычных ссылок, проверку типов, lifetime-анализ. Поэтому let r: &T = ...; внутри unsafe-блока всё равно проверяется. Контракт unsafe fn: программист гарантирует соблюдение инвариантов – нарушение = UB. unsafe без Vec не напишешь, без Mutex не напишешь, без tokio не напишешь – это фундамент Rust-экосистемы.
Sound unsafe API: любой safe-вызов не приведёт к UB – все проверки внутри unsafe-блока. Unsound: есть способ через safe-код привести к UB – баг безопасности. Обязательный инструмент для unsafe-крейтов: cargo +nightly miri test.
unsafe fn deref(p: *const i32) -> i32 { *p // разыменование сырого указателя - надо unsafe } let x = 42i32; let p: *const i32 = &x; let v = unsafe { deref(p) }; // А вот это всё ещё ловит borrow checker даже в unsafe-блоке: let mut v = vec![1]; let r = &v; unsafe { // v.push(2); // E0502: всё равно нельзя } let _ = r;
65. raw pointers vs references: четыре оси различий.
Сырые указатели (*const T, *mut T) и ссылки (&T, &mut T) отличаются по четырём осям. Безопасность: разыменование сырого указателя – unsafe; разыменование ссылки – safe, компилятор гарантирует валидность. Алиасинг: &mut T гарантированно уникальна – никакой другой указатель на тот же объект не существует в момент использования; *mut T таких гарантий не даёт и может алиасировать.
Нулевой указатель: ссылка никогда не null (это инвариант, на котором строятся оптимизации и niche-оптимизация Option<&T>); сырой указатель может быть null и требует проверки. Lifetime: ссылки всегда несут lifetime, компилятор отслеживает время жизни; сырые указатели не имеют lifetime и могут пережить данные (dangling pointer).
Сырые указатели нужны для FFI, самореференсных структур, кастомных аллокаторов и lock-free структур данных, где правила borrow-checker принципиально не применимы.
fn main() { let x = 42_i32; let y = 10_i32; // Сырые указатели: создать safe, разыменовать unsafe let r1: *const i32 = &x as *const i32; let r2: *const i32 = &y as *const i32; // Алиасинг разрешён для *const/*mut let alias = r1; // два указателя на одно место - ок для raw // Нулевой указатель let null: *const i32 = std::ptr::null(); println!("null? {}", null.is_null()); // true unsafe { println!("r1={}, r2={}", *r1, *r2); // Арифметика указателей через offset let arr = [1_i32, 2, 3, 4, 5]; let ptr = arr.as_ptr(); println!("arr[3] = {}", *ptr.add(3)); // 4 } // Ссылка: всегда ненулевая, lifetime отслеживается let r: &i32 = &x; println!("ref={}", r); // safe, не нужен unsafe }
66. MaybeUninit: как корректно работать с неинициализированной памятью.
MaybeUninit<T> – тип для работы с потенциально неинициализированной памятью без UB. Прямое чтение через *ptr неинициализированных байт – UB в Rust и C/C++: компилятор вправе считать, что такого не происходит, и делать недопустимые оптимизации. MaybeUninit::uninit() создаёт неинициализированное значение, MaybeUninit::new(val) – инициализированное.
Запись производится через .write(val), чтение – через небезопасный .assume_init() (вы гарантируете, что значение было записано). Основное применение: инициализация больших массивов без копирования дефолтного значения, работа с FFI-буферами, реализация кастомных коллекций. ptr::addr_of_mut!(place) даёт адрес поля без создания промежуточной ссылки – важно для полей в MaybeUninit<Struct>.
После assume_init() MaybeUninit забывает о своём содержимом: drop не вызывается автоматически до этого момента – нужно самостоятельно дропать при ошибках.
use std::mem::MaybeUninit; fn main() { // Инициализация массива без дефолтного значения для T let mut arr: [MaybeUninit<String>; 3] = unsafe { MaybeUninit::uninit().assume_init() // массив MaybeUninit safe }; for (i, elem) in arr.iter_mut().enumerate() { elem.write(format!("item_{}", i)); } // Преобразование в инициализированный массив let initialized = unsafe { // После записи всех элементов - safe std::ptr::read(&arr as *const _ as *const [String; 3]) }; println!("{:?}", initialized); // FFI-паттерн: передать буфер в C-функцию let mut buf = MaybeUninit::<[u8; 256]>::uninit(); // unsafe { c_function(buf.as_mut_ptr() as *mut u8, 256); } // let result = unsafe { buf.assume_init() }; }
67. UnsafeCell: единственный легальный путь к &mut через &.
UnsafeCell<T> – единственный тип в Rust, который легально предоставляет изменяемость через общую ссылку (&T). Без UnsafeCell любое изменение данных через &T (не &mut T) – неопределённое поведение, даже если никакого конфликта нет физически.
Это основа всей interior mutability: Cell<T>, RefCell<T>, Mutex<T>, RwLock<T>, Atomic* – все используют UnsafeCell внутри. Компилятор использует отсутствие UnsafeCell для оптимизаций: если функция принимает &T без UnsafeCell, LLVM может кешировать значение в регистре и не перечитывать из памяти.
UnsafeCell::get() возвращает *mut T – raw pointer для дальнейшей работы. UnsafeCell не является Sync, что корректно: безопасный multi-thread доступ требует дополнительных гарантий (Mutex, атомики).
use std::cell::UnsafeCell; // Простой Cell-подобный тип на основе UnsafeCell struct MyCell<T> { value: UnsafeCell<T>, } impl<T: Copy> MyCell<T> { fn new(val: T) -> Self { MyCell { value: UnsafeCell::new(val) } } fn get(&self) -> T { // Безопасно: нет aliasing с &mut unsafe { *self.value.get() } } fn set(&self, val: T) { // Изменение через &self - легально только через UnsafeCell unsafe { *self.value.get() = val; } } } // UnsafeCell не Sync - нельзя шарить между потоками без доп. синхронизации // impl<T> !Sync for MyCell<T> {} // автоматически, т.к. UnsafeCell: !Sync fn main() { let cell = MyCell::new(42_i32); cell.set(100); // изменяем через &self - legit! println!("{}", cell.get()); // 100 }
68. FFI: repr(C) и зачем он нужен.
По умолчанию Rust переставляет поля структур для минимизации padding и улучшения layout. Для FFI это катастрофа: C-код ожидает строгий фиксированный порядок полей. #[repr(C)] фиксирует: поля в порядке объявления, padding по правилам C ABI платформы, выравнивание каждого поля по его align. #[repr(C, packed)] убирает padding вообще (поля могут быть невыровнены – UB при разыменовании ссылок к полям!).
#[repr(u8/u16/u32)] на enum фиксирует размер дискриминанта. #[repr(transparent)] на newtype: один нетривиальный non-ZST field, идентичный layout – для FFI без накладных расходов. Как проверить layout: cargo +nightly rustc -- -Zprint-type-sizes. Критическое правило: любая структура, передаваемая через границу FFI, должна иметь repr(C) или repr(transparent). Без этого компилятор может в любой версии изменить порядок полей – и C-код начнёт читать мусор.
// Правильно: repr(C) для FFI #[repr(C)] pub struct Vector3 { pub x: f32, pub y: f32, pub z: f32, } // layout гарантирован: [f32, f32, f32] без padding между полями #[repr(C)] pub enum Status { Ok = 0, NotFound = 1, Error = 2, } // дискриминант = int (C default), Size = 4 байта // repr(transparent): newtype с идентичным ABI #[repr(transparent)] pub struct FileHandle(i32); // size_of::<FileHandle>() == size_of::<i32>() == 4 extern "C" { fn c_open(path: *const u8, flags: i32) -> FileHandle; // ABI как i32 } // repr(C, packed) - осторожно! ссылки на невыровненные поля = UB #[repr(C, packed)] struct PackedHeader { magic: u32, version: u8, length: u16, // может быть невыровнен (после u8 = offset 5) } // ПРАВИЛО: к packed полям никогда не брать ссылки! // let h = PackedHeader { magic: 0xDEAD, version: 1, length: 100 }; // let r = &h.length; // UB: невыровненная ссылка! // Проверка layout без компиляции: use std::mem::{size_of, align_of, offset_of}; assert_eq!(size_of::<Vector3>(), 12); assert_eq!(offset_of!(Vector3, z), 8); // z начинается на байте 8
69. extern fn и паника: что будет, если паника пересечёт ABI.
Паника в Rust – либо unwinding (по умолчанию), либо abort (panic = "abort" в profile). Паника, пересекающая границу extern "C" = UB по спецификации: C ABI не предполагает stack unwinding, C-код не знает про деструкторы Rust, LLVM backend может генерировать некорректный код на пересечении ABI. С Rust 1.71 стабилизирован extern "C-unwind": явно разрешает propagation паники через C-фреймы (при условии что C-код компилировался с поддержкой unwind).
Практическое правило: любая extern "C" функция, экспортируемая из Rust, должна оборачивать тело в std::panic::catch_unwind и конвертировать панику в код ошибки. catch_unwind возвращает Result: Ok(value) или Err(Box<dyn Any>). Внутри catch_unwind нельзя использовать panic = "abort" (catch_unwind становится no-op).
Типичный паттерн C API: возвращать i32 (0 = ok, -1 = ошибка/паника), устанавливать errno или thread-local string с описанием ошибки.
#[no_mangle] pub extern "C" fn compute(n: i32) -> i32 { let result = std::panic::catch_unwind(|| { if n < 0 { panic!("negative input: {}", n); } n.checked_mul(n).expect("overflow") }); match result { Ok(v) => v, Err(_) => -1, // паника превращается в код ошибки } } // extern "C-unwind" (Rust 1.71+): паника propagates через C #[no_mangle] pub extern "C-unwind" fn risky_compute(n: i32) -> i32 { // Паника здесь propagates через C-фреймы (если они поддерживают unwind) // Используется для Rust→C→Rust цепочек if n < 0 { panic!("negative"); } n * n } // Полный паттерн с thread-local ошибкой: use std::cell::RefCell; thread_local! { static LAST_ERROR: RefCell<Option<String>> = RefCell::new(None); } #[no_mangle] pub extern "C" fn safe_fn(input: *const u8, len: usize) -> i32 { std::panic::catch_unwind(|| { let slice = unsafe { std::slice::from_raw_parts(input, len) }; process_bytes(slice) }).unwrap_or_else(|e| { let msg = if let Some(s) = e.downcast_ref::<&str>() { s.to_string() } else { "unknown panic".to_string() }; LAST_ERROR.with(|e| *e.borrow_mut() = Some(msg)); -1 }) } fn process_bytes(data: &[u8]) -> i32 { data.len() as i32 }
70. bindgen, cbindgen, cxx: три инструмента для трёх задач.
bindgen решает задачу Rust-вызова C-библиотеки: читает C/C++ заголовочные файлы (.h) и генерирует Rust FFI-биндинги с правильными типами, выравниваниями и конвенциями вызова. Применяется для libcurl, OpenSSL, SQLite и тысяч других C-библиотек. Запускается из build.rs через bindgen::Builder.
cbindgen работает в обратном направлении: берёт Rust-код с #[no_mangle] pub extern "C" fn и генерирует C/C++ заголовок – это нужно, когда C-программа должна вызывать вашу Rust-библиотеку. cxx предоставляет безопасный мост Rust-C++ с двусторонними вызовами: вы описываете границу в блоке #[cxx::bridge], и cxx генерирует glue-код на обеих сторонах с проверками типов и безопасными конвенциями передачи.
В отличие от bindgen, cxx не допускает UB на границе – нельзя передать невалидный указатель в тип, который cxx считает владеющим. Выбор: bindgen для потребления C API, cbindgen для экспорта Rust как C, cxx для глубокой двусторонней интеграции с C++.
// build.rs с bindgen fn main() { println!("cargo:rerun-if-changed=native/mylib.h"); let bindings = bindgen::Builder::default() .header("native/mylib.h") .allowlist_function("mylib_.*") .generate() .expect("bindgen failed"); let out = std::path::PathBuf::from(std::env::var("OUT_DIR").unwrap()); bindings.write_to_file(out.join("bindings.rs")).unwrap(); } // src/lib.rs include!(concat!(env!("OUT_DIR"), "/bindings.rs")); // Использование сгенерированных биндингов pub fn safe_wrapper(x: i32) -> i32 { unsafe { mylib_compute(x) } }
71. repr(transparent): newtype без рантайм-стоимости.
#[repr(transparent)] гарантирует: struct с единственным non-ZST полем имеет идентичный layout, size, align и ABI с этим полем. Это позволяет: передавать newtype через FFI-границу без каперов, использовать transmute между типами безопасно, создавать типобезопасные обёртки без накладных расходов. Правило: ровно одно не-ZST поле; остальные поля должны быть ZST (PhantomData, ()).
Отличие от repr(C): repr(C) нужен для совместимости с C-структурами (много полей, порядок полей важен). repr(transparent) – для newtype wrappers. Применения: NonZero* типы в стандарте (NonZeroU32 = transparent над u32 + ненулевой инвариант), обёртки над file descriptor, typed wrappers для FFI handle, Pin<P> (transparent над P), UnsafeCell<T> (transparent над T). cargo-semver-checks отмечает добавление/удаление repr(transparent) как breaking change в публичном API.
#[repr(transparent)] pub struct Meters(f64); // идентичный layout с f64 #[repr(transparent)] pub struct Seconds(f64); // тоже f64, но несовместим с Meters! // Проверка идентичности layout: use std::mem::{size_of, align_of}; assert_eq!(size_of::<Meters>(), size_of::<f64>()); // 8 == 8 assert_eq!(align_of::<Meters>(), align_of::<f64>()); // 8 == 8 // FFI: C ожидает double, мы передаём Meters - ABI идентичен extern "C" { fn c_sqrt(x: Meters) -> Meters; // ABI как fn c_sqrt(double) -> double } // PhantomData как второе поле - разрешено (ZST не влияет на layout) #[repr(transparent)] pub struct Fd(i32, std::marker::PhantomData<*const ()>); // !Send, !Sync // unsafe transmute безопасен для repr(transparent): fn meters_to_f64(m: Meters) -> f64 { // layout идентичен - безопасно unsafe { std::mem::transmute(m) } } // Идиоматичнее - просто обращение к полю: fn meters_to_f64_safe(m: Meters) -> f64 { m.0 } // Пример из std: NonZeroU32 = transparent над u32 use std::num::NonZeroU32; // size_of::<NonZeroU32>() == size_of::<u32>() == 4 // инвариант "не ноль" хранится без лишней памяти
72. no_std: Rust без стандартной библиотеки.
#![no_std] отключает std, оставляя доступными core (всё что не требует ОС или аллокатора: типы, трейты, математика, форматирование без alloc) и alloc (коллекции, Box, String, Vec – при наличии глобального аллокатора). Применения: ядра ОС (rust-for-linux в Linux 6.1+), embedded (Cortex-M, ESP32, RISC-V), WASM без wasi, bootloaders, прошивки.
Что работает в core без alloc: Result/Option, итераторы, форматирование через core::fmt, атомики, NonNull, математические операции, encode/decode utf-8 (без String). Что требует alloc: Vec, String, Box, Rc/Arc, HashMap, fmt::format! с аллокацией. В no_std обязательны: #[panic_handler] (что делать при панике), и если нужны аллокации – #[global_allocator].
Популярные no_std-совместимые крейты: heapless (стековые коллекции фиксированного размера), embedded-hal (HAL трейты для embedded), serde с no_std+alloc, defmt (дефраг-форматирование для embedded logging). Embassy – async runtime для embedded без std.
#![no_std] #![no_main] use core::panic::PanicInfo; // Обязательный panic handler в no_std #[panic_handler] fn panic(_info: &PanicInfo) -> ! { // На embedded: мигнуть светодиодом, залогировать через UART, зависнуть loop {} } // core работает без ОС: fn fibonacci(n: u32) -> u64 { // Нет std::iter, но есть core::iter: let mut a = 0u64; let mut b = 1u64; for _ in 0..n { let c = a + b; a = b; b = c; } a } // heapless: стековые коллекции // use heapless::Vec as HVec; // let mut v: HVec<u32, 16> = HVec::new(); // стековый Vec с max 16 элементов // v.push(42).unwrap(); // unwrap потому что может быть full // alloc при наличии аллокатора: // #[cfg(feature = "alloc")] // extern crate alloc; // use alloc::vec::Vec; // Vec с кастомным аллокатором // use alloc::string::String; // Embassy async в no_std: // #[embassy_executor::main] // async fn main(_spawner: Spawner) { // let mut led = Output::new(p.PA5, Level::High, Speed::Low); // loop { // led.toggle(); // Timer::after_millis(500).await; // } // }
73. Inline assembly: когда других вариантов нет.
asm! макрос (стабилен с Rust 1.59) позволяет встраивать инструкции CPU прямо в Rust. Синтаксис: inputs/outputs через операнды, clobbers для регистров, options для подсказок. Применения: чтение CPU-регистров (CPUID, RDTSC, CR3), MMIO с конкретными инструкциями, атомарные примитивы ниже уровня std (memory barriers), context switch в kernel-коде, специфичные SIMD или крипто-инструкции до их стабилизации.
global_asm! для ассемблерных функций на уровне файла. Options: nomem (не читает/пишет память – оптимизация), readonly (только читает), nostack (не использует стек), preserves_flags (не меняет EFLAGS), pure (детерминированный вывод). Ограничения: platform-specific, сломает cross-compilation, обычно нужен cfg(target_arch).
Для большинства задач достаточно std::arch intrin – используйте asm! только когда intrinsics нет или они не генерируют нужный код.
#[cfg(target_arch = "x86_64")] mod x86 { use std::arch::asm; // Чтение TSC (timestamp counter) - для профилирования: pub fn rdtsc() -> u64 { let lo: u32; let hi: u32; unsafe { asm!( "rdtsc", out("eax") lo, out("edx") hi, options(nomem, nostack, preserves_flags), ); } ((hi as u64) << 32) | lo as u64 } // Pause instruction для spinlock (PAUSE = экономия энергии + pipeline hint): pub fn cpu_pause() { unsafe { asm!("pause", options(nomem, nostack, preserves_flags)); } } // Явный memory fence (сильнее компиляторного): pub fn mfence() { unsafe { asm!("mfence", options(nostack, preserves_flags)); } } // CPUID для проверки фич: pub fn cpuid(leaf: u32) -> (u32, u32, u32, u32) { let (eax, ebx, ecx, edx): (u32, u32, u32, u32); unsafe { asm!( "cpuid", inout("eax") leaf => eax, out("ebx") ebx, out("ecx") ecx, out("edx") edx, ); } (eax, ebx, ecx, edx) } } // global_asm! для кода низкоуровневой инициализации: // std::arch::global_asm!( // ".global _start", // "_start:", // " xor rbp, rbp", // " call main", // );
74. SIMD: portable и target-specific.
SIMD (Single Instruction Multiple Data) выполняет одну операцию над вектором значений одновременно: например, сложение восьми f32 за одну инструкцию. В Rust два подхода. Portable SIMD (std::simd, стабилен с 1.75+): платформо-независимый API, компилятор выбирает наилучшие инструкции под целевую архитектуру.
Target-specific intrinsics: std::arch::x86_64::_mm256_add_ps – прямое отображение на AVX2-инструкции, максимальная предсказуемость, но привязка к архитектуре. Auto-vectorization: LLVM умеет векторизовать простые циклы автоматически без явного SIMD-кода – проверьте сгенерированный asm через Compiler Explorer.
Когда явный SIMD оправдан: hot loop с несвязанными элементами, специфичные алгоритмы (AES-NI, SHA), задачи обработки изображений/аудио. Помните: #[target_feature(enable = "avx2")] необходим для использования AVX2 intrinsics – без него компилятор откажется их генерировать. Обнаружение runtime через std::is_x86_feature_detected!("avx2").
// Portable SIMD (stable Rust 1.75+) use std::simd::f32x8; fn sum_simd(data: &[f32]) -> f32 { let mut sum = f32x8::splat(0.0); let chunks = data.chunks_exact(8); let remainder = chunks.remainder(); for chunk in chunks { let v = f32x8::from_slice(chunk); sum += v; } // Горизонтальное суммирование вектора let scalar_sum: f32 = sum.to_array().iter().sum(); scalar_sum + remainder.iter().sum::<f32>() } // Target-specific (AVX2): максимальный контроль #[cfg(target_arch = "x86_64")] #[target_feature(enable = "avx2")] unsafe fn add_avx2(a: &[f32; 8], b: &[f32; 8]) -> [f32; 8] { use std::arch::x86_64::*; let va = _mm256_loadu_ps(a.as_ptr()); let vb = _mm256_loadu_ps(b.as_ptr()); let res = _mm256_add_ps(va, vb); let mut out = [0.0f32; 8]; _mm256_storeu_ps(out.as_mut_ptr(), res); out }
75. Allocator API: подменить malloc.
#[global_allocator] позволяет переопределить системный аллокатор для всей программы одним атрибутом. Это нулевая стоимость на сайте вызова: все Box::new, Vec::push, HashMap::insert и другие операции, выделяющие память, автоматически пойдут через ваш аллокатор без изменения кода.
Наиболее популярные альтернативы: jemalloc (лучшие tail-latency, хорошая работа с фрагментацией на долгоживущих серверах), mimalloc от Microsoft (отличный default для большинства нагрузок, быстрый free из другого потока), snmalloc от Microsoft Research (лучшие результаты на многопоточной аллокации).
На Linux дефолтный glibc malloc часто проигрывает 20-40% при высоком параллелизме из-за глобального лока арены. Нестабильный Allocator trait позволяет передавать кастомный аллокатор прямо в коллекцию (Vec<T, A>, Box<T, A>) – это открывает арена-аллокацию и пулы объектов без unsafe кода снаружи.
use tikv_jemallocator::Jemalloc; #[global_allocator] static GLOBAL: Jemalloc = Jemalloc; // Кастомный аллокатор через трейт (nightly Allocator API) use std::alloc::{Allocator, Global, Layout}; struct ArenaAllocator { bump: usize, end: usize, } // Замена глобального аллокатора затрагивает весь процесс: // Box, Vec, HashMap, String - всё идёт через GLOBAL fn main() { // После замены глобального аллокатора код не меняется let mut v: Vec<i32> = Vec::with_capacity(1000); for i in 0..1000 { v.push(i); // использует jemalloc } // Статистика jemalloc через tikv-jemalloc-ctl // let epoch: u64 = tikv_jemalloc_ctl::epoch::mib().unwrap().advance(); // let allocated = tikv_jemalloc_ctl::stats::allocated::mib().unwrap().read(); println!("allocated {} items via jemalloc", v.len()); }
76. mem::transmute: молот, к которому не должна тянуться рука.
mem::transmute<T, U>(val: T) -> U – самая мощная и опасная операция в Rust: переинтерпретирует битовый паттерн T как U, без каких-либо проверок корректности представления. Компилятор требует только одно: size_of::<T>() == size_of::<U>(). Всё остальное – ваша ответственность.
Классические замены transmute более безопасными операциями: вместо transmute(&T) -> &U используйте ptr::read или выровненный cast; вместо transmute(fn_ptr) используйте as-cast для указателей на функции; вместо transmute(lifetime) используйте явный unsafe с объяснением инварианта.
Реальные легитимные применения немногочисленны: конвертация между числовыми типами одного размера через f32::to_bits() (лучше!), создание union-подобных структур, работа с нетипизированной памятью при реализации аллокаторов. Каждый transmute в production-коде – это признание, что type system не может выразить этот инвариант, и документация должна объяснять почему.
use std::mem; fn main() { // Легально: f32 и u32 одного размера let f: f32 = 1.0_f32; let bits: u32 = unsafe { mem::transmute(f) }; // Лучше: f32::to_bits() - та же операция но safe let bits_safe = f.to_bits(); assert_eq!(bits, bits_safe); // ОПАСНО: lifetime extension - UB если данные living меньше // let extended: &'static str = unsafe { mem::transmute(some_str_ref) }; // Безопаснее transmute для указателей на функции: let fn_ptr: fn(i32) -> i32 = |x| x + 1; let raw: usize = fn_ptr as usize; // cast, не transmute let back: fn(i32) -> i32 = unsafe { mem::transmute(raw) }; println!("{}", back(41)); // 42 // slice -> array reference (Rust 1.51+ имеет TryFrom, предпочтительнее) let slice: &[i32] = &[1, 2, 3, 4]; let arr: &[i32; 4] = unsafe { &*(slice.as_ptr() as *const [i32; 4]) }; println!("{:?}", arr); }
77. Volatile: для MMIO и embedded, не для concurrency.
read_volatile и write_volatile запрещают компилятору оптимизировать обращения: удалить «неиспользуемые» записи, объединить несколько записей в одну, переупорядочить относительно других volatile-обращений. Это нужно для Memory-Mapped IO: регистры устройств – не обычная память, каждое чтение/запись имеет побочный эффект (запуск DMA, отправка байта по UART).
Ключевое различие: volatile – инструкция компилятору («не оптимизируй»); atomic – инструкция CPU («видимость между ядрами»). Volatile ничего не говорит CPU о порядке выполнения: на ARM процессор может переупорядочить volatile-записи без memory barrier. Для MMIO на ARM нужна комбинация: volatile + memory barrier (dsb инструкция).
В Rust unsafe нет volatile keyword как в C; использовать std::ptr::read_volatile/write_volatile или volatile crate для embedded. Не использовать volatile для синхронизации между потоками – это UB, используйте Atomic.
use std::ptr; // MMIO регистры устройства по фиксированному адресу: const UART_BASE: usize = 0x4000_0000; const UART_DR: *mut u8 = UART_BASE as *mut u8; // Data Register const UART_SR: *const u32 = (UART_BASE + 4) as *const u32; // Status Register fn uart_send(byte: u8) { unsafe { // Ждём пока TX buffer не пустой: while ptr::read_volatile(UART_SR) & 0x80 == 0 {} // Каждое чтение статуса имеет эффект - volatile обязателен! ptr::write_volatile(UART_DR, byte); // Каждая запись отправляет байт - нельзя объединить два write! } } // НЕПРАВИЛЬНО для concurrency (volatile не обеспечивает видимость между ядрами): static mut SHARED: u32 = 0; fn wrong_sync(val: u32) { unsafe { ptr::write_volatile(&mut SHARED, val); // НЕ атомарно! UB в многопоточности } } // ПРАВИЛЬНО для concurrency - Atomic: use std::sync::atomic::{AtomicU32, Ordering}; static SHARED_ATOMIC: AtomicU32 = AtomicU32::new(0); fn correct_sync(val: u32) { SHARED_ATOMIC.store(val, Ordering::Release); // безопасно между потоками } // На ARM MMIO может требовать memory barrier ПОСЛЕ volatile: #[cfg(target_arch = "arm")] fn arm_mmio_write(addr: *mut u32, val: u32) { unsafe { ptr::write_volatile(addr, val); std::arch::asm!("dsb", options(nostack)); // barrier после MMIO } }
78. Sound unsafe API: контракт на границе.
Sound API - такой, что любой возможный safe-вызов не приводит к UB. Это формальная цель: внутри unsafe-блока программист пишет любые проверки; снаружи safe-функция гарантирует, что любой набор аргументов из safe-Rust безопасен. Если есть способ из safe-кода спровоцировать UB через ваш API - он unsound, баг безопасности. Классический пример: Vec::set_len(n) - unsafe, потому что вызвавший обязан гарантировать, что первые n элементов инициализированы.
// Sound: проверка внутри, snaружи безопасно pub fn safe_get<T>(slice: &[T], i: usize) -> Option<&T> { if i < slice.len() { Some(unsafe { slice.get_unchecked(i) }) } else { None } } // Unsound (баг): нет проверки, но функция safe - // внешний код может вызвать с любым i и спровоцировать UB // pub fn bad_get<T>(slice: &[T], i: usize) -> &T { // unsafe { slice.get_unchecked(i) } // }
Грабли блока на собеседовании.
Считать, что unsafe отключает borrow checker - не отключает. transmute<u8, bool>(2) - UB, потому что bool принимает только 0/1. Паника через extern "C" без catch_unwind - UB. repr(C) забыли на структуре, передаваемой в FFI - layout перетасован, C читает мусор. write_volatile вместо atomic для синхронизации между потоками - работает на x86, ломается на ARM.
Vec::set_len без инициализации элементов - UB при первом обращении. Возврат &T, ссылающегося на rawpointer-память, которая дропнулась - use-after-free.
// Граблина: unsafe не отключает borrow checker unsafe fn bad_unsafe() { let mut x = 5; let r1 = &x; // let r2 = &mut x; // ОШИБКА: всё ещё нельзя - borrow checker работает println!("{}", r1); } // Граблина: raw pointer - жизнь данных не отслеживается unsafe fn dangling_ptr() -> *const i32 { let x = 42; // локальная переменная &x as *const i32 // UB: указатель на уничтоженные данные! } // Граблина: неправильное использование transmute для lifetime-extension fn bad_transmute(s: &str) -> &'static str { // unsafe { std::mem::transmute(s) } // UB если s не живёт 'static // Правильно: убедиться что данные живут 'static Box::leak(s.to_string().into_boxed_str()) // правильный способ } // Граблина: забытый ptr::write для инициализации через сырой указатель fn init_raw() { let mut val: i32 = 0; let ptr = &mut val as *mut i32; unsafe { // *ptr = 42; // OK - assignment через raw ptr std::ptr::write(ptr, 42); // явный write - лучше для семантики } println!("{}", val); }
Блок 6: производительность (79-89)
Perf-блок - где Rust блестит и где валятся 90% middle. Знаешь Vec::with_capacity и не делаешь to_string в горячем цикле - уже выше среднего. На staff спрашивают cache-line, branch prediction и monomorphization-cost.
79. Профилирование: четыре инструмента под четыре задачи.
perf (Linux) – стандарт для CPU-профилирования: sampling с минимальным оверхедом, flame-графики через inferno или cargo-flamegraph. Запускается как perf record -g ./binary && perf report. cargo-flamegraph оборачивает perf и рисует SVG прямо из Rust-проекта одной командой.
Valgrind/Callgrind даёт точный подсчёт инструкций и кэш-промахов, но замедляет программу в 20-100x – подходит для нахождения алгоритмических проблем, а не для профилирования реальной нагрузки. heaptrack и cargo-dhat профилируют аллокации: сколько выделено, где, сколько живёт, какой пик.
samply – современная альтернатива perf для macOS/Linux с красивым UI в Firefox Profiler. Для Tokio-программ добавьте tokio-console: он показывает живое состояние задач, время ожидания пробуждений и детектирует голодающие задачи. Важно: профилируйте в --release с debug = true в Cargo.toml, иначе символы будут stripped и flamegraph будет нечитаемым.
# Cargo.toml - включить отладочные символы в release [profile.release] debug = true # CPU flamegraph cargo flamegraph --bin myapp -- --workers 4 # Профилирование аллокаций с dhat # В main.rs добавить: # #[global_allocator] # static ALLOC: dhat::Alloc = dhat::Alloc; # let _profiler = dhat::Profiler::new_heap(); # Tokio console (в Cargo.toml добавить console-subscriber) # TOKIO_CONSOLE_BIND=127.0.0.1:6669 cargo run # tokio-console http://127.0.0.1:6669
80. Inlining: четыре атрибута и когда они врут.
Инлайнинг – подстановка тела вызываемой функции на место вызова, устраняющая overhead вызова и открывающая возможности для дальнейших оптимизаций. Четыре атрибута в Rust: #[inline] – подсказка компилятору "это стоит инлайнить", но не обязательство. #[inline(always)] – принудительный инлайн, игнорирует эвристику; может увеличить размер бинаря.
#[inline(never)] – запрет инлайна; полезно для отладки, профилирования (функция видна в профиле) и паники. #[cold] – функция вызывается редко, компилятор выносит её код в "холодную" секцию, не трогая предсказатель ветвей. Когда они врут: #[inline(always)] на крупной функции раздует bycache бинарь, увеличив количество cache miss-ов в instruction cache.
#[inline] без дополнения может быть проигнорирован – LTO даёт лучший эффект для cross-crate инлайна. Правило: измеряйте через flamegraph или Compiler Explorer перед добавлением inline-атрибутов. Трейт-методы маленьких структур (геттеры, простые преобразования) – хорошие кандидаты для #[inline].
// #[inline] - подсказка; хорошо для маленьких функций #[inline] pub fn add(a: i32, b: i32) -> i32 { a + b // компилятор скорее всего заинлайнит } // #[inline(always)] - принудительно; используйте осторожно #[inline(always)] pub fn hot_path_check(x: u32) -> bool { x & 0xFF == 0 // битовая операция - хороший кандидат } // #[inline(never)] - запрет; полезно для паник и редких путей #[inline(never)] #[cold] fn report_error(msg: &str) -> ! { panic!("critical error: {}", msg); } // #[cold] - редко вызываемая функция #[cold] fn initialize_slow_path() -> Vec<u8> { // Дорогая инициализация, но происходит один раз vec![0u8; 4096] } fn main() { let result = add(1, 2); // будет заинлайнен if hot_path_check(result as u32) { report_error("unexpected zero"); // cold, инлайна нет } }
81. Избегание аллокаций: главный источник тормозов в проде.
Аллокация памяти в heap стоит дорого: системный вызов или lock на аллокаторе, инвалидация TLB, page fault при первом обращении. В hot-path каждая лишняя аллокация = 100-1000 нс. Главные источники лишних аллокаций в Rust: 1) format! – всегда аллоцирует String; замена – write! в буфер или preformatted.
2) .to_string()/.to_owned() – клонирование строки; замените на &str где возможно. 3) Vec::push в цикле без with_capacity – многократные реаллокации. 4) Box::new для каждого объекта – пул объектов или arena. 5) Замыкания захватывающие heap-данные в Box<dyn Fn>.
Инструменты диагностики: cargo-dhat профилирует аллокации по коду, #[global_allocator] с подсчётом вызовов. Стратегии: pre-allocate буферы и переиспользовать (clear() не освобождает capacity), используйте SmallVec/arrayvec для малых коллекций на стеке, Cow<&str> вместо String где данные часто не меняются.
use std::fmt::Write; fn main() { // Плохо: format! аллоцирует при каждом вызове let mut results_bad = Vec::new(); for i in 0..1000 { results_bad.push(format!("item_{}", i)); // 1000 аллокаций } // Лучше: переиспользовать буфер let mut buf = String::with_capacity(32); let mut results_good: Vec<String> = Vec::with_capacity(1000); for i in 0..1000 { buf.clear(); // не освобождает память, только len=0 write!(buf, "item_{}", i).unwrap(); results_good.push(buf.clone()); } // SmallVec: первые N элементов на стеке, overflow в heap // use smallvec::SmallVec; // let mut v: SmallVec<[i32; 4]> = SmallVec::new(); println!("done: {} items", results_good.len()); }
82. Vec и capacity: понимание роста.
Vec<T> хранит три значения на стеке: указатель на heap-буфер, длину (len) и ёмкость (capacity). При push() когда len == capacity, Vec запрашивает новый буфер и копирует все элементы. Стратегия роста в Rust: удвоение capacity (примерно x2, с поправками под аллокатор) – амортизированная стоимость push = O(1).
Vec::with_capacity(n) резервирует сразу – используйте когда финальный размер известен, избегая O(log n) перевыделений. shrink_to_fit() запрашивает уменьшение capacity до len (hint для аллокатора, не гарантировано). retain(|x| predicate(x)) фильтрует in-place за O(n) без аллокаций.
Патологический случай: построить Vec через push 1000000 элементов с capacity=0 = ~20 аллокаций и перемещений суммарно (log2(1000000) ≈ 20). С with_capacity(1000000) = 1 аллокация. extend_from_slice и append эффективнее чем поэлементный push из другого Vec. То же правило работает для HashMap: HashMap::with_capacity(n) предотвращает повторные rehash-операции если итоговый размер известен заранее.
fn main() { // Без capacity: много перевыделений let mut v1: Vec<i32> = Vec::new(); for i in 0..10 { v1.push(i); // capacity растёт: 0, 1, 2, 4, 8, 16... } println!("len={}, cap={}", v1.len(), v1.capacity()); // len=10, cap=16 // С capacity: одна аллокация let mut v2: Vec<i32> = Vec::with_capacity(10); for i in 0..10 { v2.push(i); // нет перевыделений } println!("len={}, cap={}", v2.len(), v2.capacity()); // len=10, cap=10 // shrink_to_fit let mut v3 = Vec::with_capacity(1000); v3.extend(0..10); v3.shrink_to_fit(); println!("after shrink: cap={}", v3.capacity()); // ~10 // drain / retain - без аллокаций let mut nums = vec![1, 2, 3, 4, 5, 6]; nums.retain(|x| x % 2 == 0); // [2, 4, 6] in-place println!("{:?}", nums); }
83. String и UTF-8: дорогая правда индексации.
String – гарантированно валидный UTF-8. Символы Unicode от 1 до 4 байт (ASCII – 1, кириллица – 2, emoji – 3-4). Поэтому s[i] НЕ возвращает символ – это было бы O(1) только для фиксированной длины. Rust намеренно запрещает Index<usize> для String и &str: нет способа индексировать по «символу» за O(1).
.len() – длина в байтах (O(1), хранится в поле). .chars().count() – число Unicode scalar values (O(n), линейный проход). .chars() – итератор по Unicode scalar values (char = u32 ≤ 0x10FFFF). &s[start..end] – slice по байтам, паникует если граница внутри многобайтового символа. s.get(range) – Option версия, None если граница неверна.
Для настоящих «символов» (grapheme clusters) – unicode-segmentation crate. Для производительного поиска – memchr crate или regex. Практически: большинство операций над ASCII-совместимым текстом работают через байтовые операции; для полного Unicode нужны специализированные инструменты.
fn string_operations() { let s = String::from("привет"); // кириллица: 2 байта на символ println!("bytes: {}", s.len()); // 12 (6 символов * 2 байта) println!("chars: {}", s.chars().count()); // 6 (линейный проход O(n)) // Срез по байтам - должен попасть на границу символа: // let bad = &s[0..1]; // panic! 1 - середина первого символа let ok = &s[0..2]; // "п" (первые 2 байта = первый символ) // Безопасный вариант: let safe = s.get(0..2); // Some("п") или None если граница неверна // Итерация по символам: for c in s.chars() { print!("{} ", c); } // п р и в е т // Итерация по байтам: for b in s.bytes() { print!("{:02x} ", b); } // d0 bf d1 80 ... // Найти символ по позиции (O(n)): let third: Option<char> = s.chars().nth(2); // 'и' // Правильный поиск подстроки: assert!(s.contains("вет")); // emoji: 4 байта на символ let emoji = "?"; // Rust crab assert_eq!(emoji.len(), 4); // байт assert_eq!(emoji.chars().count(), 1); // символ } // unicode-segmentation для grapheme clusters: // use unicode_segmentation::UnicodeSegmentation; // let text = "é"; // может быть 1 или 2 char (e + combining accent) // let graphemes: Vec<&str> = text.graphemes(true).collect(); // assert_eq!(graphemes.len(), 1); // всегда 1 видимый символ
84. HashMap и hash DoS: почему стандарт медленный нарочно.
Стандартный HashMap в Rust использует SipHash 1-3 – криптографически безопасный хэш, намеренно более медленный чем FNV или xxHash. Причина: защита от hash collision DoS-атак. Если злоумышленник контролирует ключи (например, параметры HTTP-запроса), он может подобрать ключи с одинаковым хэшом – вставка деградирует с O(1) до O(n), и одним запросом можно уложить сервер.
SipHash предотвращает это: он использует случайный seed (RandomState) при создании HashMap, неизвестный атакующему. Когда ключи не контролируются пользователем (числовые id, адреса памяти, фиксированные строки), можно использовать быстрый хэшер. FxHashMap из крейта rustc-hash в 2-5x быстрее на числах, AHashMap из крейта ahash – лучший баланс скорость/безопасность для большинства случаев.
IndexMap из крейта indexmap сохраняет порядок вставки при похожей скорости. Выбор: SipHash для внешних данных, AHashMap для внутренних структур, FxHash для компилятора-like задач. Заметка: std::collections::HashMap внутри использует hashbrown (на Robin Hood open addressing) – эффективная Swiss Tables реализация. AHashMap из ахаш крейта – по факту лучший баланс безопасность/скорость для большинства случаев.
use std::collections::HashMap; // Стандартный: SipHash (безопасный, умеренно быстрый) let mut map: HashMap<String, u32> = HashMap::new(); map.insert("key".to_string(), 42); // FxHashMap: быстрый, небезопасен для внешних данных // use rustc_hash::FxHashMap; // let mut fast_map: FxHashMap<u64, u32> = FxHashMap::default(); // AHashMap: быстрый + устойчивый к DoS (использует AES инструкции) // use ahash::AHashMap; // let mut ahash_map: AHashMap<String, u32> = AHashMap::new(); // Кастомный hasher для своего типа use std::hash::{Hash, Hasher}; use std::collections::hash_map::DefaultHasher; fn calculate_hash<T: Hash>(t: &T) -> u64 { let mut s = DefaultHasher::new(); t.hash(&mut s); s.finish() } fn main() { println!("{}", calculate_hash(&"hello")); // Разный хэш при каждом запуске программы (RandomState) println!("{:?}", map.get("key")); }
85. Branch prediction: за и против.
Современные CPU предсказывают исход ветвления и начинают выполнять код до вычисления условия (спекулятивное выполнение). Правильное предсказание – почти нулевая стоимость; неправильное – branch misprediction penalty: 15-20 циклов на современных CPU (очистка pipeline). Компилятор и CPU учатся на шаблонах: цикл for i in 0..1000 почти всегда предсказывает "продолжить" корректно.
Нестабильные ветвления – главный враг branch predictor-а. std::hint::likely() и unlikely() появились как nightly-функции, но в stable Rust подсказки дают через атрибуты #[cold] на редко вызываемых функциях – они помечают ветку как холодную и выносят её код в сторону. Паттерн без ветвления (branchless): условное присваивание через CMOV-инструкции вместо if/else избегает ветвления вовсе – LLVM часто делает это автоматически для простых условий. Для критических hot-loop: измеряйте PMU counter "branch-misses" через perf, прежде чем оптимизировать вручную.
fn main() { // Обычный if - CPU предсказывает ветку let data: Vec<i32> = (0..1000).collect(); // Branchless: min без ветвления (LLVM часто делает сам) fn branchless_min(a: i32, b: i32) -> i32 { // Хороший компилятор превратит это в CMOV (нет ветвления) if a < b { a } else { b } } // #[cold] - намекает компилятору что ветка редкая #[cold] fn handle_error(e: &str) -> ! { panic!("error: {}", e); } let result: i32 = data.iter() .map(|&x| branchless_min(x, 500)) .sum(); println!("{}", result); // Unpredictable branch - плохо для predictor-а let mut rng_state = 12345_u64; let mut count = 0; for _ in 0..1000 { // Псевдослучайное ветвление - высокий misprediction rate rng_state ^= rng_state << 13; rng_state ^= rng_state >> 7; if rng_state % 2 == 0 { count += 1; } } println!("count={}", count); }
86. Cache-friendly: data-oriented design.
Современные CPU имеют три уровня кеша (L1: ~32KB, L2: ~256KB, L3: ~8-32MB). Cache miss стоит 100-300 циклов против 4 циклов для L1-hit. Data-oriented design (DOD) – подход к организации данных ради максимального использования кеша. AoS vs SoA: Array of Structures хранит объекты подряд ([{x,y,z}, {x,y,z}...]), Structure of Arrays – поля отдельно ([x,x,x,...], [y,y,y,...], [z,z,z,...]).
Если алгоритм обрабатывает только x – SoA загружает только x-данные, AoS грузит ненужные y и z. ECS (Entity Component System) – архитектурный паттерн на основе DOD. False sharing: два потока модифицируют разные переменные на одной cache line (64 байта) – процессор инвалидирует линию у другого и производительность падает.
Выравнивание через #[repr(align(64))] помещает каждый атомик на свою кеш-линию. Prefetching: последовательный доступ к памяти предсказуем для hardware prefetcher; случайный (через указатели/индексы) – непредсказуем и убивает производительность.
// AoS - неэффективно если нужно только одно поле struct ParticleAoS { x: f32, y: f32, z: f32, vx: f32, vy: f32, vz: f32, mass: f32, } // SoA - эффективно для SIMD и cache при доступе к одному полю struct ParticlesSoA { x: Vec<f32>, y: Vec<f32>, z: Vec<f32>, vx: Vec<f32>, vy: Vec<f32>, vz: Vec<f32>, mass: Vec<f32>, } impl ParticlesSoA { fn update_positions(&mut self, dt: f32) { for i in 0..self.x.len() { self.x[i] += self.vx[i] * dt; self.y[i] += self.vy[i] * dt; self.z[i] += self.vz[i] * dt; } } } // False sharing: каждый atomic на своей cache line #[repr(align(64))] struct CacheAligned(std::sync::atomic::AtomicU64);
87. Monomorphization cost: цена дженериков.
Мономорфизация – процесс, при котором компилятор создаёт отдельную копию дженерик-функции для каждого конкретного типа. fn sort<T: Ord>(v: &mut Vec<T>), вызванная с Vec<i32>, Vec<String> и Vec<User>, порождает три отдельные бинарные функции.
Плюсы: нулевой runtime-оверхед, максимальные возможности для инлайнинга и оптимизаций. Минусы: раздувание бинаря (code bloat), замедление компиляции. Паттерн "generic facade + monomorphic core": публичная дженерик-функция тонкая, реальная работа – в приватной функции принимающей конкретный тип или трейт-объект. Это компромисс: один экземпляр основного кода, с дженерик-wrapper-ами лишь для zero-cost преобразований.
Крейт serde использует этот паттерн через erased_serde. Измерить code bloat: cargo bloat --release покажет размер каждой функции и общий вклад мономорфизации. LTO (link-time optimization) может убирать неиспользованные копии.
// Паттерн: thin generic wrapper + monomorphic core pub fn process<T: AsRef<str>>(input: T) { process_impl(input.as_ref()) } // Монофорфная реализация: один экземпляр в бинаре fn process_impl(input: &str) { println!("processing {} chars", input.len()); } // Сравнение с dyn Trait: один экземпляр кода, но оверхед vtable fn process_dyn(input: &dyn AsRef<str>) { process_impl(input.as_ref()); } fn main() { process("hello"); // process::<&str> - одна копия process(String::from("world")); // process::<String> - вторая копия // Но process_impl - всегда один и тот же машинный код }
88. LTO и PGO: последние 20% производительности.
LTO (Link-Time Optimization) позволяет LLVM оптимизировать код между границами крейтов, которые при обычной компиляции непрозрачны друг для друга. lto = "thin" – инкрементальный LTO: хороший баланс скорость/выигрыш, увеличивает бинарь незначительно. lto = true (fat LTO) – максимальная оптимизация, но медленная линковка и большой бинарь.
PGO (Profile-Guided Optimization) использует реальные данные о выполнении для переупорядочивания горячего кода, лучшего inline-решения и оптимизации ветвлений. Процесс трёхфазный: инструментированная сборка → профильный запуск → финальная сборка с профилем. Даёт 10-20% ускорения на реальных нагрузках. RUSTFLAGS="-C target-cpu=native" включает все инструкции процессора (AVX2, SSE4.2 и т.д.) – нельзя переносить бинарь на другие машины. codegen-units = 1 отключает параллельную кодогенерацию, давая LLVM больше контекста для оптимизаций.
# Cargo.toml - максимальная оптимизация [profile.release] lto = "thin" # thin LTO: хороший баланс codegen-units = 1 # максимум для LLVM оптимизатора opt-level = 3 debug = false # PGO - трёхфазный процесс: # 1. Инструментированная сборка # RUSTFLAGS="-Cprofile-generate=/tmp/pgo-data" cargo build --release # 2. Запустить на репрезентативной нагрузке # ./target/release/myapp --bench # 3. Объединить профили и пересобрать # llvm-profdata merge -o /tmp/merged.profdata /tmp/pgo-data # RUSTFLAGS="-Cprofile-use=/tmp/merged.profdata" cargo build --release # Для нативной архитектуры (не переносимо!) # RUSTFLAGS="-C target-cpu=native" cargo build --release
89. criterion + black_box: правильные бенчмарки.
std::time::Instant в самописном бенчмарке часто врёт: компилятор может выкинуть весь вычисляемый код, если результат не используется. criterion - стандарт де-факто: множественные итерации, статистика, обнаружение шума, регрессий между запусками, генерация HTML-отчётов. std::hint::black_box(v) защищает от оптимизаций, "пометив" v как использованную для компилятора.
Без black_box многие бенчмарки замеряют пустоту – компилятор применяет dead code elimination (видит, что результат не используется, и удаляет всё вычисление).
use criterion::{black_box, criterion_group, criterion_main, Criterion}; fn fib(n: u64) -> u64 { if n < 2 { n } else { fib(n - 1) + fib(n - 2) } } fn bench_fib(c: &mut Criterion) { c.bench_function("fib 20", |b| { b.iter(|| fib(black_box(20))) // black_box - не дать compiler-у схитрить }); } criterion_group!(benches, bench_fib); criterion_main!(benches);
Грабли блока на собеседовании.
format!() в горячем цикле - аллокация на каждой итерации. Vec::new в цикле вместо переиспользования с clear - десятки реаллокаций. Стандартный HashMap для u64-ключей с миллионами вставок - AHashMap даст x3. Индексация String по байту, ожидая символ - паника или мусор. Профилирование debug-сборки вместо release - данные бессмысленны (нет inlining, нет const folding).
Бенчмарк без black_box - компилятор выкинул работу, замерили пустоту. Inline(always) везде "для скорости" - бинарник раздут, icache промахи всё убили.
use std::fmt::Write; // Граблина: format!() в горячем цикле - аллокация на каждой итерации fn slow_log(events: &[u32]) { for &e in events { let msg = format!("event: {}", e); // 1 аллокация на событие println!("{}", msg); } } // Хорошо: переиспользовать буфер fn fast_log(events: &[u32]) { let mut buf = String::with_capacity(64); for &e in events { buf.clear(); write!(buf, "event: {}", e).unwrap(); // нет аллокации (если cap достаточен) println!("{}", buf); } } // Граблина: clone() там где достаточно & fn process(s: String) { // берёт по владению println!("{}", s); } fn main_example() { let data = String::from("hello"); // process(data.clone()); // клонирует - лишняя аллокация // process(data); // потребляет - данные moved // Лучше: изменить сигнатуру на &str fn process_ref(s: &str) { println!("{}", s); } process_ref(&data); // нет аллокации, data не moved }
Блок 7: макросы и метапрограммирование (90-95)
Макросы спрашивают редко, но если попался любитель compile-time программирования - готовьтесь. Особенно proc-macro и const fn - там много нюансов с 2024 edition.
90. macro_rules!: декларативные макросы и матчинг.
macro_rules! – pattern-matching по token tree с AST-фрагментами. Виды фрагментов: $e:expr (выражение, включая блоки и замыкания), $i:ident (идентификатор), $t:ty (тип), $p:pat (паттерн match), $s:stmt (оператор), $b:block (блок {}), $l:literal (литерал), $tt:tt (любое дерево токенов – самый гибкий). Повторы: $($x:expr),* – ноль и более через запятую, $($x:expr),+ – одно и более, $($x:expr)? – ноль или одно.
Ограничения: нет арифметики в шаблонах, нет разворачивания рекурсии на больших входах (рекурсия в шаблонах работает, но есть лимит глубины). Отладка: cargo expand (cargo-expand crate) разворачивает макросы для просмотра. Переменные в паттерне захватываются и используются в expansion. Правило «не гигиеничных» путей: типы и функции в теле макроса резолвятся в контексте определения макроса, не вызова – использовать $crate:: для стабильности.
// Базовые паттерны macro_rules: macro_rules! vec_of_strings { ($($s:expr),*) => { vec![$($s.to_string()),*] }; } let v = vec_of_strings!["hello", "world"]; // Рекурсивный макрос: macro_rules! min { ($x:expr) => ($x); ($x:expr, $($rest:expr),+) => { std::cmp::min($x, min!($($rest),+)) }; } assert_eq!(min!(3, 1, 4, 1, 5), 1); // Макрос с несколькими ветками: macro_rules! debug_or_release { (debug: $d:expr, release: $r:expr) => { #[cfg(debug_assertions)] { $d } #[cfg(not(debug_assertions))] { $r } }; } let log_level = debug_or_release!(debug: "verbose", release: "warn"); // DSL: простой статическая машина состояний macro_rules! fsm { ($state:ident { $($variant:ident),+ }) => { #[derive(Debug, PartialEq)] enum $state { $($variant),+ } }; } fsm!(TrafficLight { Red, Yellow, Green }); let light = TrafficLight::Red; // Проверить разворачивание: cargo expand --lib (с crate cargo-expand) // cargo install cargo-expand // cargo expand
91. Гигиена: почему макрос не ломает ваши имена.
Гигиена (hygiene) – свойство макроса: имена, введённые внутри макроса, не конфликтуют с именами в месте вызова. В macro_rules! гигиена частичная: let-биндинги и переменные изолированы (разные spans), но пути (paths) не изолированы – макрос может ссылаться на типы и функции по имени. Механизм: каждый токен несёт span с информацией о том, где он определён.
Компилятор разрешает имена в контексте span, не в контексте вызова. Поэтому let x = 1 внутри макроса создаёт x с «макросным» span – недоступный снаружи. proc-macro по умолчанию НЕ гигиеничен: генерируемые токены имеют call-site span и виды в контексте вызова. Для правильной гигиены в proc-macro используют quote::quote! со span из quote::Span::call_site() или quote_spanned!.
Практически: если макрос использует имя и не хочет конфликтовать – через $crate::имя_из_крейта или переименованием через let _var = ... .
// Гигиена macro_rules: внутренние имена изолированы macro_rules! swap { ($a:ident, $b:ident) => {{ let tmp = $a; // 'tmp' имеет span внутри макроса $a = $b; $b = tmp; }}; } let mut x = 1; let mut y = 2; let tmp = "наружный tmp - НЕ будет перезаписан!"; swap!(x, y); assert_eq!(x, 2); assert_eq!(y, 1); assert_eq!(tmp, "наружный tmp - НЕ будет перезаписан!"); // гигиена работает! // $crate для ссылок из крейта макроса: macro_rules! my_assert { ($e:expr) => {{ if !$e { // $crate::panic - правильный путь к panic из крейта макроса, // не из крейта пользователя $crate::panic!("assertion failed: {}", stringify!($e)); } }}; } // proc-macro: гигиена через правильные span-ы // use proc_macro2::Span; // use quote::quote; // // // Токен с call-site span - видно в месте вызова (негигиеничен): // let ident = quote::format_ident!("generated_fn"); // // // Для гигиеничного имени - span из места определения в макросе: // let ident = proc_macro2::Ident::new("tmp_var", Span::def_site());
92. Процедурные макросы: три вида.
Proc-macro живут в отдельном crate с proc-macro = true в Cargo.toml. Три типа. Derive-макросы (#[derive(MyTrait)]) – автоматическая реализация трейтов для структур/перечислений; самые частые. Attribute-like (#[my_attr] на функциях/структурах) – трансформируют элемент на котором стоят, могут полностью его заменить; пример: #[tokio::main].
Function-like (my_macro!(...)) – выглядят как обычные макросы, но работают через proc-macro; пример: sql!(). Внутри получают TokenStream и возвращают TokenStream. Ключевые крейты: syn (парсинг Rust-синтаксиса в AST), quote (генерация TokenStream из шаблонов с интерполяцией через #name), proc-macro2 (абстракция над proc_macro для использования вне macro-crate).
darling упрощает парсинг атрибутов макроса. Ошибки в proc-macro: используйте syn::Error::to_compile_error() для корректных compile-time ошибок с указанием на нужный span.
// my-derive/src/lib.rs (отдельный крейт с proc-macro = true) use proc_macro::TokenStream; use quote::quote; use syn::{parse_macro_input, DeriveInput}; #[proc_macro_derive(Describe)] pub fn derive_describe(input: TokenStream) -> TokenStream { let input = parse_macro_input!(input as DeriveInput); let name = &input.ident; let expanded = quote! { impl Describe for #name { fn describe(&self) -> String { format!("I am {}", stringify!(#name)) } } }; TokenStream::from(expanded) } // Использование в другом крейте: // use my_derive::Describe; // trait Describe { fn describe(&self) -> String; } // #[derive(Describe)] // struct MyStruct { x: i32 } // fn main() { // let s = MyStruct { x: 42 }; // println!("{}", s.describe()); // "I am MyStruct" // }
93. build.rs: код, исполняемый перед компиляцией крейта.
build.rs в корне крейта – специальный скрипт, который Cargo компилирует и запускает перед сборкой крейта. Используется для: генерации кода (protobuf, flatbuffers, bindgen), компиляции C/C++ библиотек через крейт cc, настройки линковщика, обнаружения системных библиотек через pkg-config, условной компиляции через cfg-флаги.
Взаимодействие со средой через println! в специальном формате: cargo:rerun-if-changed=path (перезапуск только при изменении файла – без этого пересборка при каждом cargo build), cargo:rustc-link-lib=native=mylib (линковать библиотеку), cargo:rustc-cfg=feature="has_openssl" (передать cfg в Rust-код), cargo:rustc-env=VAR=value (переменная окружения в compile-time).
Окружение сборки: переменные OUT_DIR (куда писать сгенерированные файлы), CARGO_CFG_TARGET_OS, CARGO_MANIFEST_DIR. Тяжёлая работа в build.rs замедляет clean-сборки – кешируйте результаты и используйте rerun-if-changed аккуратно.
// build.rs use std::env; use std::path::PathBuf; fn main() { // Перезапуск только при изменении этих файлов println!("cargo:rerun-if-changed=native/helper.c"); println!("cargo:rerun-if-changed=native/helper.h"); // Компиляция C-файла cc::Build::new() .file("native/helper.c") .compile("helper"); // Условный cfg по целевой ОС let target_os = env::var("CARGO_CFG_TARGET_OS").unwrap(); if target_os == "linux" { println!("cargo:rustc-cfg=feature="epoll_available""); } // Генерация кода в OUT_DIR let out = PathBuf::from(env::var("OUT_DIR").unwrap()); let code = format!("pub const VERSION: &str = "{}"; ", env::var("CARGO_PKG_VERSION").unwrap()); std::fs::write(out.join("version.rs"), code).unwrap(); } // src/lib.rs // include!(concat!(env!("OUT_DIR"), "/version.rs")); // #[cfg(feature = "epoll_available")] // fn use_epoll() { ... }
94. const fn: вычисления в compile-time.
const fn – функция, вычислимая в compile-time при использовании в const-контексте (инициализация const, размер массива, generic const parameter). Постепенно расширялась: Rust 1.61+ – bounds на параметрах; Rust 1.79+ – &mut в const-контексте; Rust 2024 edition – большая часть Option/Result API в const, циклы, ветвления, мутабельные ссылки.
Ограничения на 2026: нет heap-аллокаций в const-контексте (нет Vec, Box, String в const), нет floating point для сложных операций, нет Drop для некопируемых типов в некоторых позициях, нет dynamic dispatch (dyn Trait). Практические применения: таблицы поиска вычисляются один раз – попадают в .rodata секцию, нулевой рантайм-оверхед; проверка инвариантов типов в compile-time; IP-адреса из строк (IpAddr::from_str в const – можно парсить конфиг-константы); size assertions. const panic позволяет прерывать компиляцию с читаемым сообщением при нарушении инварианта.
// Базовая const fn: const fn square(n: u64) -> u64 { n * n } const SQ: u64 = square(12); // вычислено в compile-time // Look-up table: вычисляется один раз, живёт в .rodata const fn build_sin_table<const N: usize>() -> [f64; N] { let mut table = [0.0f64; N]; let mut i = 0; while i < N { // std::f64::consts::PI в const с 1.72: table[i] = (i as f64 * core::f64::consts::TAU / N as f64).sin(); i += 1; } table } const SIN_TABLE: [f64; 256] = build_sin_table(); // Compile-time проверка инварианта: const fn assert_power_of_two(n: usize) -> usize { assert!(n.is_power_of_two(), "N must be a power of two!"); n } const BUF_SIZE: usize = assert_power_of_two(1024); // OK // const BAD: usize = assert_power_of_two(100); // компиляция упадёт! // const fn с Option (Rust 2024): const fn find_first(arr: &[i32], target: i32) -> Option<usize> { let mut i = 0; while i < arr.len() { if arr[i] == target { return Some(i); } i += 1; } None } const IDX: Option<usize> = find_first(&[1, 2, 3, 4], 3); // Some(2)
95. Typestate: состояния автомата через систему типов.
Typestate - паттерн, где разные состояния объекта представлены разными типами. Builder-паттерн с обязательными полями, state-машины протоколов, fluent-API. Это превращает рантайм-инварианты в compile-time гарантии: невозможно вызвать .send() на сокете, который не подключён - вызов просто не существует в типе Disconnected. На staff собеседовании любят гонять по этому.
struct Disconnected; struct Connected; struct Socket<State> { fd: i32, _state: std::marker::PhantomData<State>, } impl Socket<Disconnected> { fn new() -> Self { Self { fd: -1, _state: Default::default() } } fn connect(self) -> Socket<Connected> { Socket { fd: 42, _state: Default::default() } } } impl Socket<Connected> { fn send(&self, _data: &[u8]) { /* только в Connected! */ } fn close(self) -> Socket<Disconnected> { Socket { fd: -1, _state: Default::default() } } } let s = Socket::new(); // s.send(&[]); // ошибка компиляции: метод не существует let s = s.connect(); s.send(&[1, 2, 3]); // OK
Грабли блока на собеседовании.
macro_rules не умеет считать (нет арифметики в шаблонах) - часто пытаются вычислить длину. Proc-macro компилируется на хост, не на target - забывают, что в нём нет cross-compile фич. const fn ограничен: нет heap-аллокаций, нет Drop в runtime-нелитеральных типах до 2024. build.rs без cargo:rerun-if-changed - cargo не пересобирает при изменении ресурса, баг сборки. Гигиена в proc-macro - забыли указать span-ы, ошибки компиляции указывают в начало макроса вместо проблемной строки.
// Граблина: macro_rules! не умеет вычислять (нет арифметики над литералами) macro_rules! double { ($x:expr) => { $x * 2 }; // ОК: это выражение, вычисляется компилятором } // macro_rules! array_size { ($n:literal) => { $n * 2 }; } - нельзя использовать как const в типе // Граблина: гигиена макросов - переменная из макроса не видна снаружи macro_rules! with_temp { ($body:block) => {{ let temp = 42; // эта переменная "гигиенична" - не конфликтует с внешними $body }}; } fn test_hygiene() { let temp = 99; with_temp!({ println!("inside macro temp is hidden"); }); println!("outer temp = {}", temp); // 99, не 42 } // Граблина: proc-macro паника вместо compile_error // Плохо: panic!("invalid input") в proc-macro - плохой UX // Хорошо: // use syn::Error; // Error::new(span, "invalid input").to_compile_error().into() // Граблина: забытый #[macro_export] - макрос недоступен из других крейтов #[macro_export] macro_rules! public_macro { () => { println!("exported!") }; }
Блок 8: архитектура и дизайн API (96-100)
Финальный блок про то, что отличает работу в проде от учебных примеров. anyhow vs thiserror - вопрос на каждом втором собесе. semver и workspace - на каждом тимлид/staff.
96. Builder: типобезопасный без 47 if-ов.
Паттерн Builder в Rust можно сделать типобезопасным через typestate: состояние Builder кодируется в generic-параметрах, и компилятор не даст вызвать build() без установки обязательных полей. Обычный подход с Option для каждого поля плюс проверки в build() – это runtime-ошибки при компиляции.
Typestate Builder – compile-time гарантии. Каждый обязательный параметр – это тип-маркер (unit struct): NoHost/HasHost. Методы переводят Builder из одного состояния в другое: set_host принимает Builder<NoHost> и возвращает Builder<HasHost>. Метод build() доступен только на Builder<HasHost, HasPort>.
Цена: больше типов, но нулевой runtime-оверхед – все проверки на этапе компиляции. Для простых builder-ов с опциональными полями достаточно обычного паттерна с Option и одним build() -> Result.
use std::marker::PhantomData; struct NoHost; struct HasHost; struct NoPort; struct HasPort; struct ConnectionBuilder<H, P> { host: Option<String>, port: Option<u16>, timeout: u64, _h: PhantomData<H>, _p: PhantomData<P>, } impl ConnectionBuilder<NoHost, NoPort> { fn new() -> Self { ConnectionBuilder { host: None, port: None, timeout: 30, _h: PhantomData, _p: PhantomData, } } } impl<P> ConnectionBuilder<NoHost, P> { fn host(self, h: impl Into<String>) -> ConnectionBuilder<HasHost, P> { ConnectionBuilder { host: Some(h.into()), port: self.port, timeout: self.timeout, _h: PhantomData, _p: PhantomData } } } impl<H> ConnectionBuilder<H, NoPort> { fn port(self, p: u16) -> ConnectionBuilder<H, HasPort> { ConnectionBuilder { host: self.host, port: Some(p), timeout: self.timeout, _h: PhantomData, _p: PhantomData } } } impl ConnectionBuilder<HasHost, HasPort> { fn build(self) -> String { format!("{}:{}", self.host.unwrap(), self.port.unwrap()) } } fn main() { let conn = ConnectionBuilder::new().host("localhost").port(8080).build(); println!("{}", conn); // ConnectionBuilder::new().build(); // ОШИБКА компилятора! }
97. thiserror vs anyhow: библиотеки против приложений.
thiserror – derive-макрос для типизированных ошибок с derive-макросом. Генерирует impl std::error::Error и impl Display. Основной инструмент для библиотек: публичный API должен экспортировать тип (пользователь может match по вариантам) и иметь чёткую обратную совместимость. #[from] автоматически генерирует impl From<SrcError>, что позволяет писать ? для преобразования.
Механика ?: разворачивается в match result { Ok(v) => v, Err(e) => return Err(From::from(e)) } – то есть помимо раннего возврата, автоматически применяет From::from(e) для конвертации типа ошибки. #[source] отмечает цепочку source() для отладки. anyhow – динамический тип ошибки для приложений и интеграционных слоёв, где caller просто пишет в лог.
anyhow::Error = Box<dyn Error + Send + Sync + static> + backtrace. .context("...") и .with_context(|| "...") добавляют контекст к цепочке. anyhow::bail!(...) = return Err(anyhow!(...)), anyhow::ensure!(cond, ...) = if !cond { bail!(...) }. Правило: библиотека = thiserror (типизированно, matchable), бинарник/сервис = anyhow (быстро, контекст, backtrace).
anyhow в публичной библиотеке – антипаттерн: downstream не может match по вариантам, должен парсить строки. Совместно использовать можно: thiserror в логике, anyhow в main/handler для простого принта ошибки.
// Библиотека - thiserror: #[derive(thiserror::Error, Debug)] pub enum DbError { #[error("connection failed: {0}")] Connection(#[from] std::io::Error), #[error("not found: {key}")] NotFound { key: String }, #[error("decode: {0}")] Decode(#[source] serde_json::Error), } // Приложение - anyhow: use anyhow::{Context, Result}; fn run(path: &str) -> Result<()> { let data = std::fs::read_to_string(path) .with_context(|| format!("чтение {}", path))?; let _: serde_json::Value = serde_json::from_str(&data) .context("парсинг JSON")?; Ok(()) }
98. Дизайн API на трейтах: четыре правила хорошего trait.
Хорошие трейты в Rust следуют четырём принципам. Минимальность: трейт должен иметь минимально необходимый набор required methods; всё остальное – provided methods с дефолтной реализацией поверх минимума (как Iterator: требует только next(), а map/filter/fold идут бесплатно).
Объектная безопасность: если трейт предназначен для dynamic dispatch, он должен быть object-safe; generic-методы помечают where Self: Sized. Blanket implementations: широкие реализации вида impl<T: Trait> AnotherTrait for T – мощный инструмент расширения, но создают orphan-конфликты если пересекаются у пользователя; проектируйте с учётом этого.
Seal pattern: если трейт не предназначен для реализации пользователями (только для использования), запечатайте его через приватный super-trait – это предотвращает случайные реализации вне крейта и даёт свободу менять детали. Семантическая когерентность: трейт должен моделировать одну чётко определённую концепцию, не несколько несвязанных.
// Минимальный трейт + богатые provided methods trait Summary { fn summarize_author(&self) -> String; // required: минимум fn summarize(&self) -> String { // provided: default реализация format!("(Read more from {}...)", self.summarize_author()) } } // Seal pattern: трейт нельзя реализовать вне крейта mod private { pub trait Sealed {} // приватный маркер } pub trait MyPublicTrait: private::Sealed { fn do_thing(&self); } // Только внутри крейта: pub struct MyType; impl private::Sealed for MyType {} impl MyPublicTrait for MyType { fn do_thing(&self) { println!("doing"); } } // Blanket impl: все типы Display получают бонус use std::fmt; trait Printable: fmt::Display { fn print(&self) { println!("{}", self); } } impl<T: fmt::Display> Printable for T {} // все Display типы
99. semver: ломкие изменения там, где не ожидали.
Semantic versioning в Rust: MAJOR.MINOR.PATCH – мажорное изменение ломает совместимость API. Но "ломкое изменение" шире, чем кажется. Очевидно ломкое: удалить публичный тип/функцию, изменить сигнатуру, добавить required-метод в трейт. Неочевидно ломкое: 1) добавить поле в публичную структуру без #[non_exhaustive] – пользователи не смогут создать её через struct literal; 2) добавить вариант в публичный enum без #[non_exhaustive] – ломает match без wildcard; 3) ужесточить bounds на generic-параметр – существующий код перестаёт компилироваться; 4) добавить метод с реализацией по умолчанию в трейт – технически не ломает, но может конфликтовать с методами downstream; 5) изменить видимость поля с pub на pub(crate).
Атрибут #[non_exhaustive] на struct/enum предупреждает пользователей: мы будем добавлять поля/варианты – заставляет использовать .. в деструктуризации. Cargo.lock: для библиотек не коммитить, для бинарей коммитить.
#[non_exhaustive] pub struct Config { pub timeout: u64, pub retries: u32, } #[non_exhaustive] pub enum Status { Ok, Error(String), } // В крейте пользователя: fn handle(status: &Status) { match status { Status::Ok => println!("ok"), Status::Error(e) => println!("err: {}", e), _ => println!("неизвестный статус"), // НЕОБХОДИМ wildcard } } // struct literal требует ..Default::default() или невозможен // внутри крейта-автора можно без .. fn make_config() -> Config { Config { timeout: 30, retries: 3 } // ok внутри крейта }
100. Workspace: правильное деление крупного проекта.
Cargo workspace - набор крейтов под одним Cargo.lock. Помогает: shared-зависимости (одна версия tokio во всех крейтах), параллельная сборка (cargo builds в топологическом порядке), workspace inheritance (с 1.64) - общие fields в [workspace.package]. Типичная структура крупного проекта: api/ для типов протокола, core/ для бизнес-логики (без IO-зависимостей, легко тестировать), service/ для бинарника-сервиса, integration-tests/ для интеграционных тестов. Циклические зависимости запрещены - принуждает к чистой архитектуре.
# Cargo.toml (корневой) [workspace] resolver = "2" members = ["api", "core", "service", "integration-tests"] [workspace.package] version = "0.5.0" edition = "2024" authors = ["team@example.com"] [workspace.dependencies] tokio = { version = "1.40", features = ["full"] } serde = { version = "1", features = ["derive"] } # core/Cargo.toml [package] name = "core" version.workspace = true edition.workspace = true [dependencies] tokio.workspace = true api = { path = "../api" }
Грабли блока на собеседовании.
Builder без type-state - забыли обязательное поле, поймали в проде через год. anyhow в публичной библиотеке - downstream не может matchиться, должен парсить строки. Trait без object-safety, когда нужен dyn в полях - переписывают полкода. Добавление поля в pub struct без non_exhaustive - minor релиз ломает users. Один большой workspace без разбиения - cargo build 10 минут. Циклические workspace-зависимости - cargo не разрешает, отлично, но люди часто упираются.
// Граблина: Builder без typestate - пропущенные обязательные поля struct BadBuilder { host: Option<String>, port: Option<u16>, } impl BadBuilder { fn build(self) -> Result<String, &'static str> { Ok(format!("{}:{}", self.host.ok_or("host required")?, self.port.ok_or("port required")?)) } } // Граблина: pub поля вместо методов - нет инкапсуляции // struct Config { pub timeout: u64 } // менять тип позже = breaking change struct ConfigGood { timeout_ms: u64 } // private impl ConfigGood { pub fn timeout(&self) -> std::time::Duration { std::time::Duration::from_millis(self.timeout_ms) } } // Граблина: слишком широкий трейт (God trait) // trait DoEverything { fn read(); fn write(); fn parse(); fn serialize(); } // Правильно: разбить на минимальные трейты trait Readable { fn read(&self) -> Vec<u8>; } trait Writable { fn write(&mut self, data: &[u8]); } // Compose: fn process(r: &impl Readable, w: &mut impl Writable) {}
Staff-блок: A1-A21 для тех, кто идёт на staff и expert
Эти темы спрашивают на staff/principal и при разработке библиотек на грани soundness. Если приходишь на обычный senior - можно пропустить, но если на staff или в core-команды (компилятор, стандартная библиотека, языковые крейты) - обязательно.
A1. GAT (Generic Associated Types).
GAT – ассоциированные типы с собственными параметрами типа или лайфтайма. Стабилизированы в Rust 1.65. Главная мотивация: LendingIterator – итератор, возвращающий значение, заимствованное из самого себя между вызовами next(). Обычный Iterator::Item не может выразить связь «Item заимствован из &mut self», потому что тип Item не имеет лайфтайм-параметра.
GAT делают это возможным: type Item<'a> where Self: 'a позволяет записать, что Item живёт ровно столько, сколько &'a self. Второй важный паттерн – семейства типов: один трейт, параметризованный по коллекции. GAT плохо ложатся на dyn Trait: дополнительный лайфтайм делает трейт не object-safe без явного for<'a>, что ограничивает динамическую диспетчеризацию.
Очень частая ловушка на собесе: GAT-трейт не object-safe без явного for<'a> в dyn Trait, поэтому Box<dyn LendingIterator> не скомпилируется напрямую – нужны дополнительные обертки типов. На собесе ожидают: реализовать LendingIterator, объяснить почему обычный Iterator не подходит и почему GAT не работают с dyn напрямую.
// Проблема без GAT: нельзя вернуть &mut self.data[i] из next() // потому что тип Item не связан с лайфтаймом &mut self trait LendingIterator { type Item<'a> where Self: 'a; // GAT: Item параметризован лайфтаймом fn next<'a>(&'a mut self) -> Option<Self::Item<'a>>; } struct WindowsMut<'s, T> { data: &'s mut [T], pos: usize, size: usize, } impl<'s, T> LendingIterator for WindowsMut<'s, T> { type Item<'a> = &'a mut [T] where Self: 'a; // GAT: возвращаем кусок, живущий 'a fn next<'a>(&'a mut self) -> Option<&'a mut [T]> { if self.pos + self.size > self.data.len() { return None; } let slice = &mut self.data[self.pos..self.pos + self.size]; self.pos += 1; Some(slice) } } // Семейство типов через GAT: trait Container { type Iter<'a> where Self: 'a; fn iter<'a>(&'a self) -> Self::Iter<'a>; } impl<T> Container for Vec<T> { type Iter<'a> = std::slice::Iter<'a, T> where T: 'a; fn iter<'a>(&'a self) -> std::slice::Iter<'a, T> { self.as_slice().iter() } }
A2. HRTB и for<'a>.
Higher-Ranked Trait Bound (HRTB) – ограничение «F реализует Fn для ЛЮБОГО лайфтайма». Записывается как for<'a> F: Fn(&'a str) -> &'a str. Смысл: «независимо от того, какой лайфтайм подставят, F продолжает работать». Это нужно когда функция принимает замыкание/трейт, который должен обрабатывать ссылки с произвольным временем жизни – например, трансформер строк, парсер, callback для итератора.
В большинстве случаев borrow checker выводит HRTB автоматически через elision: fn apply(f: impl Fn(&str)) == fn apply<'a>(f: impl Fn(&'a str)). Явный for<'a> нужен когда несколько ссылок с разными лайфтаймами и автовывод не справляется, или когда пишете сложные bounds на трейтовых объектах. Типичная ошибка на собесе: написать F: Fn(&'a str) с конкретным 'a вместо for<'a> – это ограничивает F одним лайфтаймом, а не любым.
// HRTB: f должна принимать &str с ЛЮБЫМ лайфтаймом fn apply_to_str<F>(f: F, s: &str) -> String where F: for<'a> Fn(&'a str) -> String, { f(s) } // Автовывод чаще всего делает это сам: fn apply_simple(f: impl Fn(&str) -> String, s: &str) -> String { f(s) // сахар для for<'a> Fn(&'a str) -> String } // HRTB на трейтовом объекте: fn make_mapper() -> Box<dyn for<'a> Fn(&'a str) -> usize> { Box::new(|s: &str| s.len()) } // Когда нужен явный for<>: две ссылки с независимыми лайфтаймами fn longest_via_hrtb<F>(f: F, a: &str, b: &str) -> usize where F: for<'a, 'b> Fn(&'a str, &'b str) -> usize, { f(a, b) } let r = longest_via_hrtb(|a, b| a.len().max(b.len()), "hi", "world"); assert_eq!(r, 5);
A3. Variance.
Variance – свойство параметрических типов, описывающее, как подтипирование по параметру влияет на подтипирование самого типа. Три вида: ковариантность (covariant): если 'long: 'short, то &'long T <: &'short T – можно передать долгоживущую ссылку туда, где ждут короткую; контравариантность (contravariant): fn(T) – контравариантна по T (применяется редко); инвариантность (invariant): &mut T инвариантна по T – нельзя передать &mut LongLived вместо &mut ShortLived.
Инвариантность &mut T по T – фундаментальная защита: иначе можно было бы через мутацию записать короткоживущую ссылку в долгоживущий слот. PhantomData<T> ковариантен по T, PhantomData<&mut T> инвариантен. В unsafe-коде неправильная variance = soundness-дыра: компилятор разрешит опасные касты. Miri помогает, но не всегда ловит variance-ошибки на конкретных тестах.
use std::marker::PhantomData; // Ковариантен по T и 'a (как &'a T): struct Covariant<'a, T> { data: *const T, _marker: PhantomData<&'a T>, // ковариантно: &'a T } // Инвариантен по T (как &mut T): struct Invariant<T> { data: *mut T, _marker: PhantomData<&'static mut T>, // инвариантно: &mut T } // Контравариантность (редко): struct Contravariant<T> { _marker: PhantomData<fn(T)>, // контравариантно по T } // Демонстрация: почему &mut T должна быть инвариантна fn unsafe_covariance_demo() { let mut s: &str = "long lived"; { let owned = String::from("short lived"); // ЕСЛИ бы &mut &str была ковариантна - можно было бы: // let r: &mut &'short str = &mut s; // s: &'long str, если ковариантна // *r = &owned; // записали &'short str в s: &'long str - use-after-free! // На самом деле Rust запрещает это через инвариантность } println!("{}", s); // s должна быть валидна }
A4. Subtyping лайфтаймов.
'a: 'b означает «лайфтайм 'a является подтипом 'b» – то есть 'a живёт не короче, чем 'b. Это counter-intuitive: «длиннее значит подтип» (в отличие от ООП, где подтип уже). Обоснование: если ожидается ссылка с временем жизни 'b, то ссылка с 'a (которая живёт дольше) тоже подходит – можно использовать везде, где используется 'b.
Используется в: bounds на дженериках (T: 'static = «T живёт дольше, чем static»), в where-claus для связи нескольких лайфтаймов, в GAT с where Self: 'a. Классическая задача на собесе: fn longest<'a, 'b: 'a>(x: &'a str, y: &'b str) -> &'a str – здесь 'b: 'a означает «y живёт не короче x», а возвращаем ссылку с 'a.
// 'long: 'short - 'long живёт не меньше 'short fn use_longer<'short, 'long: 'short>( longer: &'long str, _shorter: &'short str ) -> &'short str { longer // OK: 'long: 'short, значит &'long str <: &'short str } // T: 'static - тип не содержит ссылок короче 'static fn only_owned<T: 'static>(t: T) -> Box<dyn std::any::Any> { Box::new(t) // требует T: 'static для Any } // В GAT: where Self: 'a означает 'self: 'a trait LendingIter { type Item<'a> where Self: 'a; // 'self: 'a - итератор переживёт возвращаемый Item fn next<'a>(&'a mut self) -> Option<Self::Item<'a>>; } // Практический пример: связь лайфтаймов через where fn get_or_default<'a, 'b, T: Default>(opt: Option<&'a T>, def: &'b T) -> &'a T where 'b: 'a // default должен жить не меньше, чем результат { opt.unwrap_or(def) }
A5. *mut T vs *const T: variance и unsafe.
*const T ковариантен по T: если U: T (U – подтип T), то *const U: *const T. *mut T инвариантен по T: нельзя использовать *mut U где ожидается *mut T, даже если U: T. Причина: через *mut можно записать значение типа T в слот U – это нарушит инварианты. NonNull<T> ковариантен (аналогично *const T), что делает его правильным выбором для read-only умных указателей.
Ошибка авторов unsafe-коллекций: хранить *mut T в структуре с PhantomData<T> (ковариантно) вместо PhantomData<*mut T> (инвариантно). В Arc <T> и Rc<T> авторы специально подбирают PhantomData: Arc содержит PhantomData<T> (ковариантен), потому что Arc не даёт писать в T напрямую. Vec<T> ковариантен по T – правильно для владеющего контейнера.
use std::marker::PhantomData; // Правильный ковариантный умный указатель (только чтение): struct ReadPtr<T> { ptr: *const T, // *const - ковариантен } // PhantomData<T> нужен чтобы dropck знал что T может быть дропнут // Правильный инвариантный умный указатель (запись): struct WritePtr<T> { ptr: *mut T, _marker: PhantomData<*mut T>, // явная инвариантность } // ОШИБКА: вот так делать нельзя (ковариантность + mut = soundness hole): struct BadPtr<T> { ptr: *mut T, _marker: PhantomData<T>, // ковариантность при *mut - UB в некоторых паттернах } // NonNull<T> - ненулевой ковариантный указатель (как Box, Arc внутри) use std::ptr::NonNull; struct OwnedSlice<T> { ptr: NonNull<T>, // ковариантен по T - правильно для владеющей коллекции len: usize, cap: usize, } // PhantomData<T> для dropck - без него компилятор не знает что дропает T // Проверить variance: cargo +nightly rustc -- -Z print-type-sizes
A6. Pin и зачем он async.
Pin<P> – гарантия, что указуемое значение не будет перемещено в памяти пока Pin существует. Нужен для self-referential типов: структур, у которых одно поле ссылается на другое поле той же структуры. async fn генерирует именно такие структуры: локальные переменные между .await-точками становятся полями анонимного enum'а, и когда future захватывает &x где x – другое поле, оно становится self-referential.
Перемещение такой структуры сломало бы указатель. Unpin – auto trait для типов, которые безопасно перемещать даже будучи pinned (большинство обычных типов). impl !Unpin ставится вручную или генерируется для async fn стейт-машин. Pin<&mut T> – unpinned мутабельный доступ через Pin. Правило: если T: Unpin, то Pin<&mut T> эквивалентен &mut T.
Если !Unpin – перемещение через &mut невозможно без unsafe. Для полиморфного кода важно: Box<dyn Future> нужно pinned – Box::pin(future) или Pin::new(Box::new(future)).
use std::pin::Pin; use std::marker::PhantomPinned; // Self-referential структура - нужен Pin struct SelfRef { data: String, ptr: *const String, // указывает на data выше _pin: PhantomPinned, // !Unpin - запретить перемещение } impl SelfRef { fn new(data: String) -> Pin<Box<Self>> { let mut boxed = Box::pin(SelfRef { data, ptr: std::ptr::null(), _pin: PhantomPinned, }); // Устанавливаем self-reference ПОСЛЕ pin, пока адрес фиксирован: let ptr = &boxed.data as *const String; unsafe { boxed.as_mut().get_unchecked_mut().ptr = ptr; } boxed } fn get(&self) -> &str { unsafe { &*self.ptr } } } // В async: компилятор делает это автоматически для .await между ссылками async fn async_self_ref() { let data = String::from("hello"); let r = &data; // ссылка на локальную переменную tokio::time::sleep(std::time::Duration::from_millis(0)).await; // точка await // r всё ещё используется - future self-referential, нужен Pin println!("{}", r); } // Как правильно boxировать Future для коллекций: fn make_boxed_future() -> Pin<Box<dyn std::future::Future<Output = i32>>> { Box::pin(async { 42 }) }
A7. Pin projection и pin-project.
Pin projection – преобразование Pin<&mut Outer> в Pin<&mut Inner> для каждого поля. Если поле Inner: Unpin – projection безопасен всегда. Если поле !Unpin (например, вложенный Future) – projection должен гарантировать, что Outer тоже !Unpin, иначе при перемещении Outer сломается Inner.
Ручная реализация через unsafe и обязательные гарантии: (1) тип !Unpin если любое !Unpin-поле pinned; (2) impl Drop не перемещает поля из Pin; (3) никаких safe-функций, раскрывающих &mut T для pinned-поля. Макрос pin_project из crate pin-project-lite или pin-project генерирует проекции автоматически с проверкой правильности через helper-методы.
Это наиболее безопасный способ реализовывать кастомные Future. #[pin] атрибут на поле = оно получает pinned-проекцию. Поля без #[pin] получают обычную &mut-проекцию.
use pin_project_lite::pin_project; use std::future::Future; use std::pin::Pin; use std::task::{Context, Poll}; use std::time::{Duration, Instant}; pin_project! { struct TimedFuture<F> { #[pin] // это поле получит pinned-проекцию inner: F, start: Instant, // это поле получит обычную &mut-проекцию } } impl<F: Future> Future for TimedFuture<F> { type Output = (F::Output, Duration); fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> { let this = self.project(); // безопасная проекция через макрос // this.inner: Pin<&mut F> (pinned - можно pollить) // this.start: &mut Instant (обычная мутабельная ссылка) match this.inner.poll(cx) { Poll::Ready(val) => Poll::Ready((val, this.start.elapsed())), Poll::Pending => Poll::Pending, } } } fn timed<F: Future>(f: F) -> TimedFuture<F> { TimedFuture { inner: f, start: Instant::now() } } // Использование: // let (result, elapsed) = timed(some_async_fn()).await;
A8. Cancellation safety.
Future отменяется путём дропа: когда select! выбирает другую ветку, все остальные Future в этот момент дропаются. Cancel-safe future – такой, что после дропа его состояние согласовано: либо операция полностью завершена, либо полностью не началась. Нет «частично сделано». Конкретные примеры: recv() из mpsc::Receiver – cancel-safe: если дропнуть до получения значения, оно остаётся в очереди.
AsyncReadExt::read() – cancel-safe: если дропнуть, буфер не заполнен, но исходник не тронут. AsyncReadExt::read_exact() – НЕ cancel-safe: прочитала 5 байт из 10, дропнулась – 5 байт из сокета потеряны. Mutex::lock().await – cancel-safe: если дропнуть до лока, лок не взят. Документация tokio помечает методы как cancel-safe явно.
Паттерн для non-cancel-safe операций: вынести в отдельный tokio::spawn с JoinHandle, управлять отменой через CancellationToken.
use tokio::io::AsyncReadExt; use tokio::time::{sleep, Duration}; // БАГ: read_exact не cancel-safe – байты из TCP теряются при таймауте async fn read_with_timeout_bad(mut sock: tokio::net::TcpStream) -> Vec<u8> { let mut buf = vec![0u8; 100]; tokio::select! { _ = sleep(Duration::from_secs(1)) => { // read_exact дропнулась в середине - часть байт потеряна! vec![] } _ = sock.read_exact(&mut buf) => buf } } // ПРАВИЛЬНО: cancel-safe обёртка через задачу с CancellationToken use tokio_util::sync::CancellationToken; async fn read_with_cancel(mut sock: tokio::net::TcpStream) { let token = CancellationToken::new(); let token2 = token.clone(); let handle = tokio::spawn(async move { let mut buf = vec![0u8; 100]; tokio::select! { _ = token2.cancelled() => None, result = sock.read_exact(&mut buf) => result.ok().map(|_| buf), } }); // Отменяем через токен, а не дропом future: tokio::time::sleep(Duration::from_secs(1)).await; token.cancel(); let _ = handle.await; } // Список cancel-safe операций tokio: // - mpsc::Receiver::recv() // - oneshot::Receiver (send остаётся в канале) // - TcpStream::read() (один read-call) // - tokio::time::sleep // - JoinHandle::await (abort отдельно)
A9. async fn в state machine.
Компилятор разворачивает async fn в анонимную enum-структуру (стейт-машину). Каждое .await создаёт отдельное состояние enum; переменные, живущие через .await-точку, становятся полями этого состояния. Размер стейт-машины = максимальный размер среди всех «путей» через CFG, не сумма всех переменных. На практике это значит: один большой локал в ветке, которая никогда не совпадает с другой большой веткой, не раздувает стейт-машину вдвое.
Send-ness future: если хоть одно поле стейт-машины !Send, весь future !Send – нельзя tokio::spawn без LocalSet. Самая частая причина «future is not Send»: MutexGuard через .await, Rc, RefCell захваченные между точками. Стейт-машина обычно !Unpin: нельзя перемещать после первого poll. Поэтому Box::pin или pin! нужны перед poll. cargo +nightly rustc -- -Zunpretty=mir можно посмотреть стейт-машину в MIR.
// Что делает компилятор с async fn: async fn example(x: String, y: i32) -> String { let z = format!("{}-{}", x, y); // переменная z tokio::time::sleep(std::time::Duration::from_millis(1)).await; // точка 1 // z живёт через await - будет в поле стейт-машины z } // Примерный эквивалент (упрощённо): enum ExampleStateMachine { Start { x: String, y: i32 }, AfterSleep { z: String, sleep_fut: tokio::time::Sleep }, Done, } // Почему future не Send если MutexGuard через await: use std::sync::Mutex; async fn bad_guard(m: &Mutex<i32>) { let guard = m.lock().unwrap(); // guard: MutexGuard<i32> - !Send tokio::time::sleep(std::time::Duration::from_millis(1)).await; // guard живёт через await = стейт-машина содержит MutexGuard = !Send println!("{}", *guard); // tokio::spawn(bad_guard(&m)) // E0277: future is not Send } // Правильно: освободить guard до await async fn good_guard(m: &Mutex<i32>) { let val = { m.lock().unwrap().clone() }; // guard дропнут до await tokio::time::sleep(std::time::Duration::from_millis(1)).await; println!("{}", val); // val: i32 - Copy, Send }
A10. async fn в трейтах.
До Rust 1.75 async-методы в трейтах не поддерживались напрямую. Обходной путь – async_trait crate: генерирует Pin<Box<dyn Future + Send>> для каждого вызова, одна heap-аллокация на вызов. С Rust 1.75 RPITIT (Return Position Impl Trait In Trait) стабилизирован: async fn в трейтах работает напрямую через ассоциированный тип-future.
Проблема: этот future не имеет Send-bound по умолчанию. Для multi-thread tokio это значит, что трейт без явного Send нельзя использовать в tokio::spawn. Три решения: (1) #[trait_variant::make(MyTrait: Send)] из trait-variant crate – генерирует Send-вариант автоматически; (2) async_trait::async_trait – классика, аллокация; (3) ручное объявление через ассоциированный тип: type HandleFut<'a>: Future<Output = ()> + Send + 'a. Для dyn Trait: RPITIT не работает с dyn напрямую, нужен async_trait или manual boxed future.
// Rust 1.75+ без Send (работает для LocalSet / current_thread): trait ServiceBasic { async fn handle(&self, req: u32) -> u32; } // С Send через trait-variant (рекомендуется для tokio multi-thread): use trait_variant::make; #[make(ServiceSend: Send)] trait Service { async fn handle(&self, req: u32) -> u32; } // Теперь есть два трейта: Service (без Send) и ServiceSend (+ Send) struct Impl; impl Service for Impl { async fn handle(&self, req: u32) -> u32 { req + 1 } } // tokio::spawn требует ServiceSend: // tokio::spawn(async move { impl_instance.handle(1).await }); // Для dyn - async_trait: #[async_trait::async_trait] trait ServiceDyn: Send + Sync { async fn handle(&self, req: u32) -> u32; } // Box<dyn ServiceDyn> работает, но каждый вызов = heap alloc // Ручной ассоциированный тип (без крейтов): trait ServiceManual { type HandleFut<'a>: std::future::Future<Output = u32> + Send + 'a where Self: 'a; fn handle<'a>(&'a self, req: u32) -> Self::HandleFut<'a>; } impl ServiceManual for Impl { type HandleFut<'a> = std::future::Ready<u32> where Self: 'a; fn handle<'a>(&'a self, req: u32) -> std::future::Ready<u32> { std::future::ready(req + 1) } }
A11. Stacked Borrows и Tree Borrows.
Stacked Borrows – формальная операционная модель алиасинга в Rust, разработанная Ralf Jung. Каждая ссылка при создании получает уникальный тег. Для каждого адреса памяти есть «стек» тегов: активные заимствования. Когда &mut T создаётся, все предыдущие теги выше него в стеке «заморожены» или инвалидируются. Использование инвалидированного тега = UB.
Miri реализует проверку по этой модели. Tree Borrows (2023) – более либеральная модель: вместо стека тегов – дерево, что разрешает больший класс корректных программ (особенно паттерны с raw-указателями в unsafe). Практика: cargo +nightly miri test обязателен для unsafe-крейтов. Stacked Borrows отловит use-after-free через raw pointer, нарушения aliasing после reborrow. Не все unsafe-паттерны проверяются: Miri конкретизирует один путь выполнения, а не все.
// Пример нарушения Stacked Borrows, пойманного Miri: fn stacked_borrows_ub() { let mut x = 42i32; let r1 = &mut x; // тег T1, стек: [T1] let r2 = &mut *r1; // reborrow: тег T2, стек: [T1, T2] let _r1_again = &mut x; // T1 = ИНВАЛИДИРОВАН (T2 сейчас активен) // Использование r2 после инвалидации T1 (через *r1_again) = UB // Miri поймает: "attempting to reborrow with tag T2, which is no longer valid" } // Tree Borrows разрешает некоторые из этих паттернов: // В частности: "protected" теги в Tree Borrows // Позволяют чаще использовать interior mutability через raw ptr // Как запустить Miri: // rustup component add miri // cargo +nightly miri test // Что Miri проверяет через Stacked/Tree Borrows: // - use-after-free через raw pointers // - алиасинг &mut через два разных пути // - нарушение инвариантов ptr::read/write // - неинициализированная память (через MaybeUninit) // Что Miri НЕ проверяет: // - все interleaving'и потоков (для этого Loom) // - все пути выполнения (только конкретный тест)
A12. Memory ordering детально.
Подробная версия вопроса 36 с акцентом на тонкие случаи. Relaxed: только атомарность (нет переупорядочения между самими операциями на атомике, но операции с другими адресами могут быть перенесены куда угодно). Arc::strong_count инкремент Relaxed – правильно, потому что сам факт инкремента атомарен, но видимость данных внутри Arc контролируется Release/Acquire при финальном декременте.
Release: все предшествующие store/load не переносятся после этой операции. Acquire: все последующие store/load не переносятся до этой. Пара Release (у писателя) + Acquire (у читателя) создаёт happens-before: всё написанное писателем до Release-store видно читателю после Acquire-load того же атомика. AcqRel: и Release, и Acquire в одной RMW-операции.
SeqCst: + глобальный тотальный порядок всех SeqCst-операций. На x86 TSO (Total Store Order) почти SeqCst даром – именно поэтому алгоритмы с Relaxed могут «случайно» работать на x86, но ломаются на ARM/RISC-V с более слабой моделью памяти. Инструмент проверки: Miri (конкретный поток) или Loom (все interleavings).
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; use std::sync::Arc; // Правильный lock-free флаг с данными: struct DataReady { data: AtomicUsize, ready: AtomicBool, } impl DataReady { fn publish(&self, val: usize) { self.data.store(val, Ordering::Relaxed); // данные self.ready.store(true, Ordering::Release); // Release: data.store видна после } fn read(&self) -> Option<usize> { if self.ready.load(Ordering::Acquire) { // Acquire: синхронизация с Release Some(self.data.load(Ordering::Relaxed)) // теперь data точно видна } else { None } } } // Тонкость Arc: почему финальный декремент Acquire, не Relaxed? // Producer thread 1: decrements strong_count (Relaxed) // Producer thread 2: decrements strong_count (Relaxed) // Thread 3 (последний): decrements strong_count, получает 0 // -> fetch_sub возвращает 1 (было 1, стало 0) // -> ЗДЕСЬ нужен Acquire-fence: синхронизация со всеми Release-декрементами // -> после fence гарантировано, что T::drop видит все предыдущие записи // Проверка: #[cfg(loom)] // #[cfg(test)] // mod tests { // use loom::sync::atomic::AtomicBool; // loom::model(|| { /* тест с перебором interleavings */ }); // }
A13. Loom.
Loom – model checker конкурентного кода: перебирает все возможные interleaving-и атомарных операций, проверяя каждый на корректность. Обычные тесты запускают один конкретный порядок операций. Loom запускает все возможные порядки и находит гонки, которые проявляются лишь при редком расписании потоков. Использование: заменить импорты std::sync на loom::sync и std::thread на loom::thread, добавить loom::model(|| { ...
}) вокруг теста, запустить с RUSTFLAGS="--cfg loom" cargo test. Ограничения: комбинаторный взрыв – 3 потока с 4 атомиками дают тысячи interleavings. Loom не проверяет внешние события (epoll, I/O). Loom не заменяет Miri (Miri проверяет UB через Stacked Borrows, Loom проверяет логику concurrency). Их следует использовать вместе: Miri для soundness unsafe, Loom для правильности lock-free алгоритмов.
// Cargo.toml: // [dev-dependencies] // loom = "0.7" // В тесте - заменяем std на loom: #[cfg(test)] mod loom_tests { #[cfg(loom)] use loom::sync::atomic::{AtomicUsize, Ordering}; #[cfg(loom)] use loom::sync::Arc; #[cfg(loom)] use loom::thread; #[cfg(not(loom))] use std::sync::atomic::{AtomicUsize, Ordering}; #[cfg(not(loom))] use std::sync::Arc; #[cfg(not(loom))] use std::thread; // Тестируем простой счётчик с двумя потоками #[test] #[cfg_attr(loom, ignore)] // запускать только с --cfg loom fn test_counter_loom() { #[cfg(loom)] loom::model(|| { let counter = Arc::new(AtomicUsize::new(0)); let c1 = counter.clone(); let c2 = counter.clone(); let t1 = thread::spawn(move || c1.fetch_add(1, Ordering::Relaxed)); let t2 = thread::spawn(move || c2.fetch_add(1, Ordering::Relaxed)); t1.join().unwrap(); t2.join().unwrap(); // Loom проверит ВСЕ interleavings и убедится что результат всегда 2 assert_eq!(counter.load(Ordering::Relaxed), 2); }); } } // Запуск: RUSTFLAGS="--cfg loom" cargo test --test concurrency_test
A14. Niche optimization.
Niche – диапазон битовых паттернов, которые невалидны для данного типа. Компилятор использует niche для хранения дискриминанта enum без дополнительных байт. Классический пример: Option<&T> имеет тот же размер, что и &T – None кодируется нулевым указателем (который невалиден как ссылка). NonZeroU32 имеет niche = 0, поэтому Option<NonZeroU32> = 4 байта (0 = None).
bool имеет niche 2..=255 – Option<bool> = 1 байт. Enum с вариантами без данных и вариантами с полями с niche могут быть оптимизированы. Отключить оптимизацию: #[repr(C)] на enum. Посмотреть layout: cargo +nightly rustc -- -Zprint-type-sizes или std::mem::size_of. Практическое применение: при дизайне API – использовать NonZero-типы вместо Option<u32> где 0 семантически невалиден – экономит память и ускоряет код.
use std::mem::size_of; use std::num::NonZeroU32; // Классические niche-оптимизации: assert_eq!(size_of::<&u32>(), 8); assert_eq!(size_of::<Option<&u32>>(), 8); // None = null ptr assert_eq!(size_of::<NonZeroU32>(), 4); assert_eq!(size_of::<Option<NonZeroU32>>(), 4); // None = 0 assert_eq!(size_of::<bool>(), 1); assert_eq!(size_of::<Option<bool>>(), 1); // None = 2 (невалидный байт) assert_eq!(size_of::<Box<u32>>(), 8); assert_eq!(size_of::<Option<Box<u32>>>(), 8); // None = null // Дизайн API с NonZero для экономии: struct UserId(NonZeroU32); // Option<UserId> = 4 байта (не 8 как Option<u32>) // Vec<Option<UserId>> будет вдвое компактнее Vec<Option<u32>> // repr(C) убирает niche-оптимизацию: #[repr(C)] enum WithReprC { None, Some(NonZeroU32), } assert_eq!(size_of::<WithReprC>(), 8); // 4 для дискриминанта + 4 для значения // Без repr(C) - компилятор оптимизирует: enum WithoutReprC { None, Some(NonZeroU32), } assert_eq!(size_of::<WithoutReprC>(), 4); // niche!
A15. Custom allocator и GlobalAlloc.
GlobalAlloc – unsafe trait с четырьмя методами: alloc, dealloc, alloc_zeroed, realloc. #[global_allocator] статический объект, реализующий GlobalAlloc, заменяет стандартный glibc malloc для всей программы. Самые популярные альтернативы: jemalloc (низкая фрагментация, хорош для long-running servers), mimalloc (Microsoft, балансирует latency и throughput), snmalloc (структурированный аллокатор с thread-owner semantics).
На многопоточных нагрузках mimalloc/jemalloc стабильно быстрее glibc на 20-50% по причинам: per-thread кеши выделений, меньший lock contention, лучшая дефрагментация. Allocator API (nightly/2024 edition) – передавать кастомный аллокатор конкретной коллекции: Vec::new_in(&arena), что позволяет арены, пулы объектов, bounded-аллокаторы без влияния на весь процесс. Для embedded и real-time – аллокаторы с детерминированным временем (fixed-size block allocators).
// Cargo.toml: // [dependencies] // mimalloc = { version = "0.1", default-features = false } use mimalloc::MiMalloc; #[global_allocator] static GLOBAL: MiMalloc = MiMalloc; fn main() { // Теперь все Vec, String, Box используют mimalloc let v: Vec<u64> = (0..1_000_000).collect(); println!("allocated {} items", v.len()); } // Ручная реализация GlobalAlloc (например, для embedded): use std::alloc::{GlobalAlloc, Layout}; struct BumpAllocator { heap_start: usize, heap_end: usize, next: std::sync::atomic::AtomicUsize, } unsafe impl GlobalAlloc for BumpAllocator { unsafe fn alloc(&self, layout: Layout) -> *mut u8 { let alloc_start = align_up( self.next.load(std::sync::atomic::Ordering::Relaxed), layout.align() ); let alloc_end = alloc_start + layout.size(); if alloc_end > self.heap_end { return std::ptr::null_mut(); // OOM } self.next.store(alloc_end, std::sync::atomic::Ordering::Relaxed); alloc_start as *mut u8 } unsafe fn dealloc(&self, _ptr: *mut u8, _layout: Layout) { // Bump allocator не освобождает по одному (только сброс всей арены) } } fn align_up(addr: usize, align: usize) -> usize { (addr + align - 1) & !(align - 1) }
A16. Dropck.
Dropck (drop check) – подсистема borrow checker, гарантирующая, что в момент вызова Drop::drop все ссылки, хранимые в типе, ещё валидны. По умолчанию: если структура содержит ссылку или тип T, то при дропе компилятор требует, чтобы T и все ссылки жили не меньше самой структуры. Это часто излишне строго: Vec не обращается к элементам в своём Drop (просто освобождает память), но компилятор не знает этого без явного обещания.
Атрибут #[may_dangle] (nightly) на параметр T в impl Drop говорит: «я не обращаюсь к T в drop, можно допустить, что T дропнулся раньше». Это позволяет таким конструкциям работать: let guard = vec.iter(); drop(vec); drop(guard). Vec и Box используют may_dangle внутри. Важно: если использовать may_dangle неправильно (обращаясь к T в drop) – soundness hole.
PhantomData<T> в структуре «напоминает» dropck, что T всё равно должен пережить структуру (нужно если хранится raw pointer на T).
// Проблема без may_dangle: // struct MyVec<T> { ptr: *mut T, len: usize, cap: usize } // impl<T> Drop for MyVec<T> { fn drop(&mut self) { /* освобождаем память */ } } // Компилятор скажет: T должен пережить MyVec (даже если мы не используем T в drop) // PhantomData исправляет dropck семантику: use std::marker::PhantomData; struct MyVec<T> { ptr: *mut T, len: usize, cap: usize, _marker: PhantomData<T>, // сообщает dropck: мы "владеем" T, не трогаем после дропа } // Нестабильный #[may_dangle] для продвинутых коллекций: // unsafe impl<#[may_dangle] T> Drop for MyVec<T> { // fn drop(&mut self) { // // Только освобождение памяти - не читаем содержимое элементов T // unsafe { // std::alloc::dealloc( // self.ptr as *mut u8, // std::alloc::Layout::array::<T>(self.cap).unwrap() // ); // } // } // } // #[may_dangle]: "обещаю не читать T в drop, поэтому T может умереть раньше меня" // Практическая демонстрация проблемы: fn dropck_demo() { let v: Vec<String> = vec!["a".to_string()]; let iter = v.iter(); // заимствование v drop(v); // без may_dangle это бы не скомпилировалось // iter может быть дропнут после v благодаря may_dangle в Vec drop(iter); }
A17. Sealed trait.
Sealed trait – паттерн, при котором трейт нельзя реализовать снаружи вашего крейта, даже если он публичный. Применяется для: гарантии что добавление новых методов в трейт не сломает чужой код (breaking change нет если никто не реализует), документирования «только для внутреннего использования», создания расширяемых перечислений через трейты.
Механизм: добавить приватный suptertrait sealed::Sealed, реализовать только для «разрешённых» типов внутри своего крейта. Снаружи: pub trait MyTrait: sealed::Sealed не реализовать – потому что Sealed из другого крейта, а mod sealed – приватный. Правило #[doc(hidden)] можно добавить на sealed-модуль чтобы скрыть из документации.
Паттерн часто используется в async-трейтах, extensibility в библиотеках (futures::Future в ранних версиях), type-state machine API.
// В вашей библиотеке: mod sealed { // Приватный трейт-маркер - снаружи не видно, не реализуемо pub trait Sealed {} } // Публичный трейт с sealed suptertrait pub trait Encoder: sealed::Sealed { fn encode(&self, data: &[u8]) -> Vec<u8>; // Добавляем методы - никаких breaking changes для внешних имплементаций fn encoded_len(&self, data: &[u8]) -> usize { self.encode(data).len() } } // Только внутри нашего крейта можно реализовать: pub struct Base64Encoder; impl sealed::Sealed for Base64Encoder {} impl Encoder for Base64Encoder { fn encode(&self, data: &[u8]) -> Vec<u8> { // реализация base64... data.to_vec() // упрощено } } pub struct HexEncoder; impl sealed::Sealed for HexEncoder {} impl Encoder for HexEncoder { fn encode(&self, data: &[u8]) -> Vec<u8> { data.iter().flat_map(|b| format!("{:02x}", b).into_bytes()).collect() } } // Снаружи: // use your_lib::{Encoder}; // OK, трейт публичный // struct MyEncoder; // impl Encoder for MyEncoder { ... } // E0277: sealed::Sealed не реализован // impl your_lib::sealed::Sealed for MyEncoder {} // E0603: модуль приватный
A18. Type-state pattern.
Type-state – паттерн кодирования состояний объекта в типовой системе: вместо рантайм-проверок используются разные типы для каждого состояния. Преимущества: незаконные переходы не компилируются (compile-time enforcement), документация через типы («что разрешено в каком состоянии» видно из сигнатур), нулевой рантайм-оверхед – PhantomData для состояния, нет полей.
Типичные применения: builder с обязательными полями, State-машины протоколов (HTTP request: Idle → Headers → Body → Sent), подключение к базе данных (Disconnected → Connecting → Connected → Transaction), file handle (Open → Reading → Closed). Ограничения: взрывной рост типов при большом числе состояний; плохо ложится на коллекции гетерогенных объектов (нужен dyn Trait или enum).
На staff собесе часто просят реализовать конкретный state machine – например, TCP handshake или HTTP connection lifecycle.
// State-machine TCP-соединения через typestate use std::marker::PhantomData; struct Closed; struct Connecting; struct Established; struct Closing; struct TcpConn<State> { fd: i32, _state: PhantomData<State>, } impl TcpConn<Closed> { fn new() -> Self { TcpConn { fd: -1, _state: PhantomData } } fn connect(self, addr: &str) -> Result<TcpConn<Established>, std::io::Error> { // реальное подключение... println!("connecting to {}", addr); Ok(TcpConn { fd: 42, _state: PhantomData }) } } impl TcpConn<Established> { fn send(&self, data: &[u8]) -> usize { // только в Established! data.len() } fn close(self) -> TcpConn<Closed> { println!("closing fd {}", self.fd); TcpConn { fd: -1, _state: PhantomData } } } // Компилятор запрещает некорректные переходы: fn usage() { let conn = TcpConn::new(); // conn.send(&[]); // ОШИБКА: метод send не существует для TcpConn<Closed> let conn = conn.connect("127.0.0.1:8080").unwrap(); conn.send(&[1, 2, 3]); // OK let _closed = conn.close(); // conn.send(&[]); // ОШИБКА: conn moved }
A19. mem::transmute pitfalls.
transmute<A, B> переинтерпретирует битовый паттерн A как B. Компилятор статически проверяет size_of::<A>() == size_of::<B>(). Это самый опасный примитив: очень легко получить UB. Критические ловушки: (1) bool: допускает только 0 и 1 – transmute любого другого u8 = UB (компилятор оптимизирует под это допущение); (2) enum: transmute u8 в enum без проверки дискриминанта = UB если значение вне вариантов; (3) fat pointer (&dyn Trait): layout меняется между компиляциями и версиями – transmute fat pointer = UB; (4) лайфтаймы: transmute стирает лайфтаймы – основной способ создать use-after-free в Rust; (5) нарушение invariantов: &str должен быть валидным UTF-8 – transmute &[u8] в &str без проверки = UB.
Безопасные альтернативы: bytemuck::cast/cast_slice для POD-типов (Pod trait + проверки), f32::from_bits/to_bits для float↔int, std::ptr::from_raw_parts для fat pointer reinterpretation.
use std::mem::transmute; // ОШИБКИ (UB): // let b: bool = unsafe { transmute(2u8) }; // UB: bool допускает 0 или 1 // let s: &str = unsafe { transmute(&[0xFF, 0xFE][..]) }; // UB: невалидный UTF-8 // let longer: &'static str = unsafe { transmute("short") }; // UB: лайфтайм стёрт // ПРАВИЛЬНО - безопасные альтернативы: // 1. Float ↔ integer: from_bits/to_bits let f = 1.5f32; let bits = f.to_bits(); // u32 = 0x3FC00000 let back = f32::from_bits(bits); // безопасно, нет UB assert_eq!(f, back); // 2. POD-слайсы через bytemuck: use bytemuck::{Pod, Zeroable}; #[derive(Copy, Clone, Pod, Zeroable)] #[repr(C)] struct Color { r: u8, g: u8, b: u8, a: u8 } let raw: &[u8] = &[255, 128, 0, 255]; let colors: &[Color] = bytemuck::cast_slice(raw); // с проверкой alignment и размера // 3. Lossless numeric conversions через as: let x: u32 = 42; let y: i32 = x as i32; // не transmute! // 4. Если ОЧЕНЬ нужно transmute - хоть assert layout: unsafe fn transmute_checked<A, B>(a: A) -> B { assert_eq!(std::mem::size_of::<A>(), std::mem::size_of::<B>()); assert_eq!(std::mem::align_of::<A>(), std::mem::align_of::<B>()); // Это не делает transmute безопасным, но хоть проверяет layout transmute::<A, B>(a) }
A20. unsafe impl Send/Sync.
Send и Sync – unsafe auto traits: если компилятор выводит их неправильно (или не может вывести), программист может реализовать вручную с unsafe. Это обещание компилятору: программист гарантирует безопасность. unsafe impl Send: «я гарантирую, что перемещение значения в другой поток безопасно». Применяется когда: структура содержит *mut T (не Send по умолчанию), но обёртка безопасно синхронизирует доступ.
unsafe impl Sync: «я гарантирую, что &T можно разделить между потоками». Применяется когда: структура содержит raw-указатели с внутренней синхронизацией через atomic. Требования к комментарию при unsafe impl: задокументировать инварианты, которые гарантируют безопасность. Это критично для code review: без комментария нет способа проверить корректность.
Ошибочный unsafe impl Send/Sync при несоблюдении инвариантов – data race = UB, никакого рантайм-сигнала не будет (в отличие от panic).
use std::sync::atomic::{AtomicPtr, Ordering}; // Структура с raw pointer - компилятор не выводит Send автоматически struct AtomicSlot<T> { ptr: AtomicPtr<T>, } // UNSAFE: ручная реализация Send и Sync // Safety: AtomicPtr предоставляет atomic load/store, которые безопасны между потоками. // T: Send + Sync гарантирует, что операции с T безопасны из разных потоков. unsafe impl<T: Send + Sync> Send for AtomicSlot<T> {} unsafe impl<T: Send + Sync> Sync for AtomicSlot<T> {} impl<T> AtomicSlot<T> { fn new() -> Self { AtomicSlot { ptr: AtomicPtr::new(std::ptr::null_mut()) } } fn store(&self, val: Box<T>) { self.ptr.store(Box::into_raw(val), Ordering::Release); } fn load(&self) -> Option<&T> { let p = self.ptr.load(Ordering::Acquire); if p.is_null() { None } else { Some(unsafe { &*p }) } } } // Явное снятие Send/Sync через PhantomData: struct NotSend { inner: String, _not_send: std::marker::PhantomData<*const ()>, // *const снимает Send и Sync } // Проверить что Send/Sync выведены правильно: fn assert_send<T: Send>(_: &T) {} fn assert_sync<T: Sync>(_: &T) {} let slot: AtomicSlot<String> = AtomicSlot::new(); assert_send(&slot); assert_sync(&slot);
A21. Lock-free SPSC ring buffer.
SPSC (Single-Producer Single-Consumer) ring buffer – классический lock-free паттерн для передачи данных между двумя потоками без мьютекса. Структура: head (позиция читателя) и tail (позиция писателя) как AtomicUsize, массив фиксированного размера. Producer: читает head Acquire, проверяет не полон ли буфер, пишет данные, делает Release-store tail.
Consumer: читает tail Acquire, проверяет не пуст ли, читает данные, делает Release-store head. Ordering: tail.store(Release) → head.load(Acquire) создаёт happens-before: данные записанные до Release видны после Acquire. Критически важна проблема false sharing: если head и tail на одной кеш-линии, каждая операция инвалидирует кеш другого ядра.
Решение: разнести head и tail на разные кеш-линии (padding 64 байта). Готовые реализации: rtrb и crossbeam-queue::ArrayQueue.
use std::sync::atomic::{AtomicUsize, Ordering}; use std::cell::UnsafeCell; const SIZE: usize = 1024; // должна быть степенью 2 // Разносим head и tail на разные кеш-линии (64 байта = кеш-линия x86) #[repr(C)] struct SpscQueue<T> { _pad0: [u8; 64], head: AtomicUsize, // позиция читателя _pad1: [u8; 56], tail: AtomicUsize, // позиция писателя _pad2: [u8; 56], buf: [UnsafeCell<std::mem::MaybeUninit<T>>; SIZE], } // Safety: потокобезопасен при одном producer и одном consumer unsafe impl<T: Send> Send for SpscQueue<T> {} unsafe impl<T: Send> Sync for SpscQueue<T> {} impl<T> SpscQueue<T> { fn push(&self, val: T) -> bool { let tail = self.tail.load(Ordering::Relaxed); // только producer читает tail let head = self.head.load(Ordering::Acquire); // Acquire: видим последний dequeue if tail.wrapping_sub(head) == SIZE { return false; } // полный unsafe { (*self.buf[tail & (SIZE-1)].get()).write(val); } self.tail.store(tail.wrapping_add(1), Ordering::Release); // Release: данные видны consumer true } fn pop(&self) -> Option<T> { let head = self.head.load(Ordering::Relaxed); // только consumer читает head let tail = self.tail.load(Ordering::Acquire); // Acquire: видим последний enqueue if head == tail { return None; } // пустой let val = unsafe { (*self.buf[head & (SIZE-1)].get()).assume_init_read() }; self.head.store(head.wrapping_add(1), Ordering::Release); // Release: освобождаем слот Some(val) } } // Готовые battle-tested реализации: // rtrb::RingBuffer - SPSC, lock-free, без unsafe в пользовательском коде // crossbeam_queue::ArrayQueue - MPMC, lock-free, фиксированная ёмкость
Сколько часов занимает подготовка по уровням
Оценки честные, в часах активной работы (читать, разбирать, писать примеры, гонять cargo). Не календарных дней. Реальный темп 1-3 часа в день, итого календарь умножайте на 3-5.
Раздел |
База |
Средне |
Быстро |
|---|---|---|---|
Владение, заимствование, лайфтаймы (1-15) |
20-30 ч |
8-12 ч |
3-5 ч |
Типы, трейты, обобщения (16-30) |
18-25 ч |
7-10 ч |
3-4 ч |
Конкурентность и параллелизм (31-45) |
20-30 ч |
10-14 ч |
4-6 ч |
Async и runtime (46-62) |
25-35 ч |
12-18 ч |
6-10 ч |
Unsafe, FFI (63-78) |
20-30 ч |
10-15 ч |
5-8 ч |
Производительность (79-89) |
12-18 ч |
6-10 ч |
3-5 ч |
Макросы (90-95) |
10-15 ч |
5-8 ч |
2-4 ч |
Архитектура API (96-100) |
8-12 ч |
4-6 ч |
2-3 ч |
Staff-блок (A1-A21) |
25-40 ч |
12-20 ч |
6-12 ч |
Итого |
158-235 ч |
74-113 ч |
34-57 ч |
В календаре. База - 3-5 месяцев в темпе 1-2 часа в день. Средне - 6-10 недель. Быстро - 2-4 недели.
Что съедает больше всего у каждой категории.
База.
Лайфтаймы, async, unsafe = 60% времени. Эти три темы построены на интуициях, которых в других языках нет. Лайфтаймы первые 5-10 часов вообще не "щёлкают", потом резко становятся понятнее.
Средне.
Async cancellation, Send/Sync через await, lock-free паттерны. Концепции знакомы по другим языкам, но Rust требует точности в типах, и здесь часы садятся на споры с компилятором.
Быстро.
Продвинутые темы (GAT, HRTB, Pin internals, variance, Stacked Borrows). По остальным разделам - восстановление формулировок.
Один вопрос в одной сессии раскладывается так.
Этап |
База |
Средне |
Быстро |
|---|---|---|---|
Прочитать вопрос, попробовать ответить в голове |
5 |
3 |
2 |
Прочитать разбор |
10-15 |
5-8 |
3-5 |
Скопировать пример в cargo, запустить |
5-10 |
3-5 |
2-3 |
Сломать пример, посмотреть ошибку компилятора |
10-20 |
5-10 |
3-5 |
Прочитать смежные источники |
15-30 |
5-15 |
3-8 |
Записать своими словами |
10-15 |
5-8 |
2-5 |
Итого, минут на вопрос |
55-95 |
26-49 |
15-28 |
Тяжёлые вопросы (lifetimes elision, pin projection, memory ordering, soundness pitfalls) умножайте на 1.5-2. Лёгкие (Copy/Clone, From/Into) делятся на 2.
10 самых частых ловушек, на которых валятся даже опытные
Это не топ-10 вопросов, а топ-10 ошибок в ответах. Если на собесе вы их избегаете - вы уже в верхнем квартиле кандидатов.
"move - это копирование". Нет, move - это передача владения. Для String это копирование 3 машинных слов в стеке плюс инвалидация исходника, без аллокации.
"Rc thread-safe потому что подсчёт ссылок". Нет, счётчик неатомарный. Rc не Send. Между потоками - Arc.
"static значит вечный объект". Нет, T: static значит "тип не содержит нестатических ссылок". Объект может быть дропнут хоть сразу.
"MutexGuard передаётся через await нормально". НЕТ. Future становится не Send. На multi-thread tokio = ошибка компиляции. Решение: вынести guard в блок до await, или
tokio::sync::Mutex."select! отменяет ветки безопасно". Только cancel-safe. read_exact,
Sender::send- НЕ cancel-safe. Данные могут потеряться."unsafe отключает borrow checker". НЕТ. Unsafe разрешает только 5 конкретных вещей (raw ptr deref, unsafe fn, mutable static, unsafe trait, union). Borrow checker остаётся.
"transmute u8 в bool работает потому что 1 байт". UB. bool валиден только при значениях 0 или 1. Компилятор оптимизирует исходя из этого инварианта.
"Relaxed достаточно для lock-free". Только для счётчиков без зависимости от других данных. Для синхронизации видимости - Acquire/Release.
"impl Trait и dyn Trait взаимозаменяемы". Нет. impl = статическая диспетчеризация + один тип на месте. dyn = vtable + гетерогенные коллекции. impl быстрее, dyn гибче.
"async fn в трейтах - просто пиши и работает". Работает с 1.75 через RPITIT, но Send-bound нужен явно: fn foo() -> impl Future + Send. Для dyn - либо async_trait с heap allocation, либо #[trait_variant].
"Clone вместо &str в сигнатуре". Принимать
Stringтам, где достаточно&str– лишняя аллокация на каждый вызов. Для строк, не нуждающихся в изменении – всегда предпочитайте&strилиCow<'_, str>.unwrap() без контекста в prod-коде. Паника с сообщением без стека (в рилиз сборке). Используйте .expect("почему это не должно упасть") или пробрасывайте ошибку через
?с помощью anyhow.
Что спрашивают в разных компаниях: паттерны по индустриям
За последние 14 собесов сложилась картина: разные типы компаний фокусируются на разных темах. Полезно знать заранее, на что налегать.
Тип компании |
Что спрашивают активно |
Что почти не спрашивают |
|---|---|---|
Финтех / трейдинг (HFT) |
Memory ordering, lock-free структуры, кеш-локальность, no_std, отсутствие аллокаций, SIMD |
Async, web-фреймворки, ORM, dyn Trait |
Облачная инфраструктура (Cloudflare, AWS уровень) |
tokio internals, cancel safety, scheduler, networking (TCP/UDP, TLS), производительность HTTP, observability |
FFI с C++, no_std, embedded |
Databases / storage (ScyllaDB, InfluxDB) |
Lock-free, custom allocators, memory layout, SIMD, atomics, B-tree/LSM, unsafe для производительности |
HTTP middleware, async-trait, derive макросы |
Embedded / IoT |
no_std, embassy, RTIC, прерывания, allocator-free паттерны, heapless, repr(C), volatile |
tokio, sqlx, http, async-trait |
Crypto / blockchain |
Zero-copy serialization, SIMD, secp256k1/ed25519, no_std, formal verification паттерны, kani, miri |
Web, ORM, observability |
Web/API (стартапы) |
axum, sqlx, serde, error handling, basics ownership, базовый async |
GAT, HRTB, variance, unsafe, custom alloc |
Game dev / графика |
SoA vs AoS, кеш-локальность, ECS, unsafe для производительности, GPU buffers, wgpu |
async-trait, axum, sqlx |
ML / data (Polars, Burn, Candle) |
SIMD, Allocator, no_std для edge/inference, FFI с тензорными либами (libtorch, ONNX), ndarray, parallelism через rayon, safetensors формат, кастомные аллокаторы |
HTTP, async, dyn Trait |
OS / системные (Redox) |
no_std, allocator API, кастомные коллекции, unsafe paint, FFI, ABI, bootloaders |
tokio, axum, deserialize |
На что обратить внимание. Если вы senior из веба идёте в HFT - 80% подготовки на memory ordering и atomics, 20% на остальное. Если из embedded идёте в облако - наоборот: учить tokio, cancel safety, async traits. Универсального senior Rust собеса не бывает - всё привязано к домену.
Чек-лист за 14 дней до собеса
Две недели до собеса и нужен быстрый покрывающий проход. Раскладка по дням.
День 1-3.
Владение, заимствование, лайфтаймы, базовые трейты (вопросы 1-30). 4-5 часов в день. К концу дня 3 должны без подсказки рассказать что такое move, чем отличаются &T от &mut T, как работает elision, чем Rc отличается от Arc.
День 4-6.
Конкурентность и async (31-62). Минимум один практический пример с tokio::select! и одним Arc<Mutex<T>>. К концу дня 6 знаете 5 cancel-safe и 5 не-cancel-safe операций.
День 7-9.
Unsafe, FFI, производительность (63-89). Прогнать один пример через Miri, посмотреть один cargo bench с criterion. К концу дня 9 умеете объяснить что отключает unsafe (5 пунктов) и почему transmute u8 в bool это UB.
День 10-11.
Макросы и архитектура (90-100). Быстро, читать как cheatsheet. Помнить разницу macro_rules vs proc-macro.
День 12-13.
Staff-блок (A1-A21) выборочно по позиции. На staff - все. На senior - в первую очередь A6, A8 (cancel safety), A9 (async state machine), A11 (Stacked Borrows), A12 (memory ordering), A14 (niche). A13 (loom) – для compiler-team/staff, не обязательно для senior.
День 14.
Mock-собес или решение задачи из tasks/ с таймером. На реальном собесе важна не глубина каждого ответа, а скорость попадания в суть.
Что НЕ делать в эти 14 дней.
Не читать The Rust Programming Language с нуля. За 14 дней не успеете, и она не про собесы.
Не учить crate-ы наизусть. Знание axum API не поможет, если не понимаете cancel safety.
Не углубляться в proc-макросы, если не идёте в core-команды.
Не делать pet-project за неделю до собеса. Лучше повторить материал и прорешать 3-5 задач.
Полезные ссылки
Develp10/rustinterviewquiestions - исходный репозиторий с полными разборами 100 вопросов + staff-блок A1-A21. MIT.
Develp10/rust-roadmap-ru - русскоязычный roadmap по Rust по уровням от джуна до senior.
The Rust Programming Language - официальная книга. Покрывает 70% вопросов из разделов 1-30.
The Rustonomicon - книга про unsafe, variance, Drop check, exception safety, FFI. Отвечает на половину вопросов A1-A20.
Rust Atomics and Locks (Mara Bos) - бесплатно онлайн. Покрывает разделы 31-45 и A12-A15. Лучшая книга по memory ordering.
Asynchronous Programming in Rust - официальная async-book. Pin, Future, executor.
Async-await internals by Tyler Mandry - как async fn компилируется в state machine. Отвечает на A9.
Rust Performance Book - сборник практик оптимизации.
Rust API Guidelines - официальный чек-лист дизайна публичного API.
Miri - интерпретатор MIR, ловит UB в unsafe-коде.
Loom - model checker конкурентного кода.
Crust of Rust (Jon Gjengset) - видео-разборы стандартной библиотеки в реальном времени. Лучший разбор лайфтаймов, итераторов, channels, async.
Learn Rust With Entirely Too Many Linked Lists - книга про связные списки. Через это упражнение поднимаются почти все темы из разделов 1-15 и 63-78.
This Week in Rust - еженедельная рассылка с RFC, релизами, статьями.
Without Boats - блог Boats про async, Pin, дизайн стандартной библиотеки.
Tokio: Bridging with sync code – официальный гайд по spawn_blocking, LocalSet и смешанным async/sync-коду.
LazyLock docs – стандартная замена
once_cell::sync::Lazyс Rust 1.80.Loom – model checker для lock-free кода, проверяет все interleaving-и потоков.
cargo-expand – разворачивает макросы до Rust-кода, незаменим при отладке proc-macro и macro_rules.
Веду телеграм-канал t.me/rust_code - люблю Rust, пишу про кодинг с ИИ и без, заходите.
Спасибо за внимание. Если по статье есть вопрос, или вы недавно проходили собес на Rust и есть с чем поделиться (особенно если вопросы были не из этих 100) - расскажите в комментариях, разберём.
Комментарии (3)

Cheater
03.06.2026 15:37Вроде всё по делу, но кривой машинный язык в паре мест режет глаз.
unsafe без Vec не напишешь, без Mutex не напишешь, без tokio не напишешь – это фундамент Rust-экосистемы.
Што
На ассемблерном уровне “владение” – это статическое отслеживание компилятором
При чём здесь ассемблер, “владение” это сущность на уровне .rs кода, в LLVM IR его нету уже
вызывается явно, не активирует deref-coercion в подписях
это signature что ли? Странный способ выражаться. “При приведении типов”
свою structкту-tuple-обёртку, теперь обёртка наша - можно реализовать что угодно. Bесплатно в рантайме
Опечатки
что Future делает только когда его polлят
Клёвое усреднение между поллить и рофлить

vyacheslavchulkin
03.06.2026 15:37Тут бы вообще попасть на техсобес, а то такое чувство что вакансии размещают чтобы поиграться, дальше разговоров с HR дело не идёт.
Dhwtj
Для задротов студентов
Вот интереснее
Назови топ-3 способа реализации и обоснуй когда какие применять:
Конечный автомат
Shared mutable state
Обработка ошибок
Способы связей модулей и видимость
Будет понятно, не запутается ли разработчик через год в своих соплях