← Назад к списку тем

20. Внутреннее устройство компилятора и рантайма

Borrow checker и NLL, MIR, мономорфизация, vtable для dyn Trait, паника и unwinding, отсутствие GC/рантайма.

Пайплайн компиляции: от исходника до бинарника

Компилятор rustc проходит через несколько промежуточных представлений, каждое из которых упрощает исходный код для следующего этапа анализа или оптимизации.

  • AST (Abstract Syntax Tree) — результат парсинга исходного текста, ещё близок к синтаксису.
  • HIR (High-level IR) — "desugared" AST: for превращён в цикл с Iterator, синтаксический сахар убран, здесь работает вывод типов (type inference).
  • MIR (Mid-level IR) — представление в виде графа базовых блоков с явным control flow; именно здесь работает borrow checker.
  • LLVM IR — генерируется из MIR, передаётся в LLVM для машинно-независимых и машинно-зависимых оптимизаций.
  • Машинный код — итоговый ассемблер целевой платформы, собираемый в объектные файлы и линкуемый в бинарник.
$ rustc --emit=mir main.rs      # вывести MIR в файл
$ rustc --emit=llvm-ir main.rs  # вывести LLVM IR
$ cargo rustc -- --emit=asm     # итоговый ассемблер
🔑 Зачем это знать: понимание пайплайна объясняет, почему borrow checker работает на MIR (а не на исходном тексте) — это позволяет анализировать реальный поток управления, включая ранние возвраты и ветвления, а не текстовую структуру кода.

MIR — представление, на котором работает borrow checker

MIR — это граф базовых блоков (basic blocks), похожий на байт-код: каждый блок содержит последовательность простых операций (присваивания, вызовы) и заканчивается терминатором (переход, условный переход, возврат, паника). В отличие от AST/HIR, здесь явно видны все точки, где значение может быть перемещено, заимствовано или уничтожено (drop).

fn example(cond: bool) -> i32 {
    let x = 10;
    if cond {
        x + 1
    } else {
        x + 2
    }
}
// упрощённый вид MIR (структура, не точный вывод rustc):
fn example(_1: bool) -> i32 {
    let _0: i32;      // возвращаемое значение
    let _2: i32;      // x
    let _3: i32;      // временное для x + 1 / x + 2

    bb0: {
        _2 = const 10_i32;
        switchInt(_1) -> [0: bb2, otherwise: bb1];
    }
    bb1: {
        _3 = Add(_2, const 1_i32);
        _0 = _3;
        goto -> bb3;
    }
    bb2: {
        _3 = Add(_2, const 2_i32);
        _0 = _3;
        goto -> bb3;
    }
    bb3: {
        return;
    }
}

Borrow checker анализирует именно этот граф: он строит для каждой переменной множество точек программы, в которых заимствование должно оставаться валидным, и проверяет непротиворечивость (нет одновременного &mut и любого другого доступа). MIR также используется для мономорфизации generics, constant evaluation (const fn) и оптимизаций уровня компилятора Rust ещё до передачи в LLVM (например, устранение неиспользуемых drop-глифов).

NLL — Non-Lexical Lifetimes

До Rust 2018 время жизни заимствования формально длилось до конца лексической области видимости (до закрывающей }), даже если переменная фактически больше не использовалась. NLL (стабилизирован в Rust 2018) сделал анализ заимствований точным по потоку управления на MIR: заимствование заканчивается там, где происходит последнее реальное использование, а не там, где заканчивается блок.

fn main() {
    let mut v = vec![1, 2, 3];

    let first = &v[0];       // заимствование &v начинается
    println!("{first}");   // последнее использование `first` — здесь заимствование "фактически" кончается

    v.push(4);              // до NLL: ошибка компиляции (заимствование ещё "живо" лексически)
                          // с NLL: компилируется — компилятор видит, что first больше не используется
}

Без NLL (в старом lexical-lifetime анализе) этот код не компилировался бы, потому что заимствование &v формально пересекалось бы с областью видимости first до конца блока — даже несмотря на то, что first не используется после println!. NLL требует MIR, потому что нужен граф потока управления, а не текстовые границы блоков — именно поэтому анализ заимствований физически не мог быть точным до появления MIR как промежуточного представления.

✅ Практический эффект NLL: код, который "интуитивно кажется правильным" (заимствование закончилось до конфликтующей операции), в большинстве случаев действительно компилируется. Многие "борьбы с borrow checker", встречающиеся в старых туториалах до 2018 года, сегодня неактуальны.

Мономорфизация generics

Generic-функция в Rust не компилируется в единый код с диспетчеризацией по типу в рантайме (как стёртые дженерики в Java или тип-параметризованный байткод) — вместо этого компилятор генерирует отдельную копию машинного кода для каждой уникальной комбинации типов-параметров, с которыми функция реально вызывается в программе. Это называется мономорфизацией (monomorphization).

fn largest<T: PartialOrd + Copy>(list: &[T]) -> T {
    let mut largest = list[0];
    for &item in list {
        if item > largest {
            largest = item;
        }
    }
    largest
}

fn main() {
    let ints = vec![1, 5, 3];
    let floats = vec![1.2, 5.6, 3.1];

    largest(&ints);   // компилятор генерирует largest::<i32>
    largest(&floats); // и отдельно largest::<f64>
}
// в бинарнике окажутся ДВЕ независимые, полностью оптимизированные функции —
// как если бы вы вручную написали largest_i32 и largest_f64

Плюс — каждая мономорфизированная версия оптимизируется индивидуально: LLVM видит конкретный тип, может инлайнить операции сравнения, использовать нужные регистры и SIMD-инструкции — отсюда "zero-cost": вызов generic-функции по итоговому машинному коду неотличим от вызова функции, написанной вручную под конкретный тип. Минус — каждая уникальная комбинация типов даёт собственную копию машинного кода, что увеличивает размер бинарника (code bloat) и время компиляции при большом числе инстанциаций.

// Способ снизить code bloat: вынести неполиморфную часть логики
// в отдельную non-generic функцию, чтобы мономорфизировался только тонкий слой
fn process<P: AsRef<str>>(path: P) -> std::io::Result<String> {
    process_inner(path.as_ref()) // generic-обёртка тонкая, мономорфизируется много раз, но дёшево
}

fn process_inner(path: &str) -> std::io::Result<String> {
    std::fs::read_to_string(path) // вся тяжёлая логика — не generic, существует в одном экземпляре
}

dyn Trait и vtable — динамическая диспетчеризация

Альтернатива мономорфизации — dyn Trait, использующий диспетчеризацию через таблицу виртуальных функций (vtable), как в C++ или Go-интерфейсах. Значение типа &dyn Trait или Box<dyn Trait> — это "толстый указатель" (fat pointer): пара из указателя на данные и указателя на vtable с адресами методов конкретного типа.

trait Shape {
    fn area(&self) -> f64;
}

struct Circle { radius: f64 }
struct Square { side: f64 }

impl Shape for Circle {
    fn area(&self) -> f64 { std::f64::consts::PI * self.radius * self.radius }
}
impl Shape for Square {
    fn area(&self) -> f64 { self.side * self.side }
}

fn main() {
    // один Vec может хранить разные конкретные типы за общим интерфейсом —
    // это то, что мономорфизация в принципе не может выразить в одной коллекции
    let shapes: Vec<Box<dyn Shape>> = vec![
        Box::new(Circle { radius: 2.0 }),
        Box::new(Square { side: 3.0 }),
    ];

    for shape in &shapes {
        println!("{}", shape.area()); // вызов через vtable: индирекция на этапе рантайма
    }
}

Вызов shape.area() компилируется в чтение адреса функции из vtable по фиксированному смещению и косвенный вызов (indirect call) — это на 1-2 обращения к памяти дороже прямого вызова и мешает инлайнингу (компилятор не знает статически, какая реализация будет вызвана).

КритерийGenerics (мономорфизация)dyn Trait (vtable)
Диспетчеризациястатическая, на этапе компиляциидинамическая, через таблицу указателей
Инлайнинг вызововвозможен полностьюзатруднён (indirect call)
Размер бинарникарастёт с числом инстанциацийодна копия кода для всех типов
Гетерогенные коллекцииневозможны напрямуюестественны (Vec<Box<dyn Trait>>)
Время компиляциибольше при многих типахменьше, код скомпилирован один раз
🔑 Практическое правило: используйте generics, когда набор конкретных типов известен на этапе компиляции и важна максимальная производительность (библиотеки, горячие пути); используйте dyn Trait, когда нужна гетерогенная коллекция, плагинная архитектура, или когда мономорфизация грозит неприемлемым раздутием бинарника.

Паника и unwinding

По умолчанию паника в Rust запускает stack unwinding — раскрутку стека кадр за кадром с вызовом деструкторов (Drop::drop) для всех живых на данный момент значений, аналогично исключениям в C++. Если паника происходит внутри другой паники (например, в Drop::drop) — процесс немедленно завершается через abort.

struct Guard(String);

impl Drop for Guard {
    fn drop(&mut self) {
        println!("освобождение: {}", self.0);
    }
}

fn might_panic() {
    let _g = Guard("resource".into());
    panic!("что-то пошло не так"); // _g.drop() всё равно выполнится при unwinding
}

fn main() {
    let result = std::panic::catch_unwind(|| {
        might_panic();
    });
    println!("паника перехвачена: {}", result.is_err());
}

Альтернативный режим — panic = "abort" в профиле сборки: паника сразу завершает процесс без раскрутки стека и вызова деструкторов. Это уменьшает размер бинарника (не нужны unwinding-таблицы) и немного ускоряет обычный путь выполнения, но делает catch_unwind бесполезным — паника становится необратимым завершением процесса.

// Cargo.toml
[profile.release]
panic = "abort"  # нельзя перехватить панику через catch_unwind; часто используется
                 # для встраиваемых систем и минимизации размера бинарника
⚠️ catch_unwind — не try/catch: catch_unwind не предназначен для обработки штатных ошибок (для этого есть Result) — он нужен на границах, где паника не должна пересечь FFI-границу или обрушить весь процесс целиком (например, в потоковом пуле веб-сервера, где паника одного запроса не должна валить остальные).

Отсутствие GC и рантайма

В отличие от Go, Java, C# или языков со сборкой мусора, у Rust нет фонового потока/паузы GC и нет обязательного управляющего рантайма поверх операционной системы. Управление памятью полностью статично выведено компилятором через систему владения (ownership) и заимствований (borrowing): каждое значение освобождается детерминированно, ровно в момент выхода владельца из области видимости, через вызов Drop::drop, вставленный компилятором в MIR.

fn main() {
    {
        let data = Vec::<u8>::with_capacity(1_000_000);
        // работа с data
    } // здесь компилятор вставляет вызов drop(data) — освобождение немедленное,
      // без ожидания цикла сборщика мусора
    println!("память уже освобождена");
}
АспектRust (ownership)Go/Java (GC)
Момент освобождениядетерминированный, в конце области видимостинедетерминированный, решает GC
Паузы выполненияотсутствуют (нет GC pause)возможны stop-the-world или конкурентные паузы
Накладные расходы в рантаймеотсутствуют — вся проверка на этапе компиляцииработа GC-потока/трассировки постоянно фоном
Рантайм-компонентыминимальный std (аллокатор, panic handler)полноценная VM/рантайм с планировщиком, GC
Предсказуемость latencyвысокаяниже из-за пауз GC

"Отсутствие рантайма" не означает полное отсутствие поддерживающего кода — у Rust всё равно есть минимальный слой: обработчик паники, точка входа _start/main, аллокатор. Но там нет: сборщика мусора, JIT-компилятора, зелёных потоков с собственным планировщиком (в отличие от горутин Go) — асинхронные задачи (async fn) в Rust компилируются в конечные автоматы (state machines) на этапе компиляции и требуют внешнего executor'а (Tokio, async-std), который сам по себе — обычная библиотека, а не встроенная часть языка.

✅ Итог: цена такого дизайна — сложность на этапе написания кода (борьба с borrow checker в первые недели изучения языка), а выгода — предсказуемая производительность без пауз GC и минимальный рантайм, что делает Rust пригодным там, где Go/Java/Python неприменимы в принципе: встраиваемые системы, ядра ОС, WebAssembly без рантайма, real-time системы.