Когда я впервые увидел backtrace паники в Rust, я решил, что это просто аналог исключения. Вызвал panic!, стек раскрутился, деструкторы вызвались, поток умер. Примерно как throw в C++ или raise в Python. Потом я попытался передать панику через FFI-границу, и программа молча упала без backtrace. Потом обнаружил, что catch_unwind перестаёт работать, если в Cargo.toml поменять panic = "unwind" на panic = "abort". Потом выяснил, что двойная паника (когда деструктор паникует во время раскрутки) убивает процесс безусловно, и это не баг, а так задумано.

Оказалось, что за простым panic!("oops") стоит сложная система из сменных runtime-ов, платформозависимой раскрутки стека и кучи граничных случаев...

Что делает макрос panic!

Начнём с того, во что panic!("something went wrong") превращается после раскрытия макроса. Запускаем cargo expand и видим:

::core::panicking::panic_fmt(format_args!("something went wrong"));

panic! — обычный макрос. Он вызывает функцию core::panicking::panic_fmt, передавая ей отформатированное сообщение. format_args! создаёт структуру fmt::Arguments на стеке, без аллокации кучи. Это ещё один макрос, который в compile-time генерирует описание формата и ссылки на аргументы.

Если у panic! есть аргументы форматирования:

let x = 42;
panic!("value is {}", x);

Раскрывается в:

::core::panicking::panic_fmt(format_args!("value is {}", x));

format_args! не аллоцирует String. Он создаёт на стеке структуру, которая содержит ссылку на format-строку и ссылки на аргументы. Аллокация строки с форматированным сообщением произойдёт позже, внутри panic runtime, когда сообщение будет конвертировано в payload паники.

Тут есть нюансик, edition 2015 panic!("something {}") использовало строку как есть, без попытки форматирования. Фигурные скобки не интерпретировались как placeholder. В edition 2021 и новее panic! всегда интерпретирует первый аргумент как format string. Если в вашем panic-сообщении случайно есть {}, при переходе на новый edition получите ошибку компиляции. Существуют даже отдельные внутренние функции: std::panic::panic2015, std::panic::panic2021, для каждого edition свой вариант.

Три шага внутри panic_fmt

panic_fmt делает три вещи по порядку.

Первый шаг: вызов panic hook. Перед тем как начать раскрутку стека или убить процесс, Rust вызывает зарегистрированный panic hook. По умолчанию hook печатает сообщение и (если RUST_BACKTRACE=1) backtrace в stderr. Именно поэтому при панике вы видите что-то в терминале.

Второй шаг: инкремент счётчика паник потока. У каждого потока есть thread-local счётчик текущих паник. Если при вызове panic_fmt счётчик уже больше нуля, значит мы паникуем во время паники (двойная паника), и нужно немедленно abort. Об этом дальше.

Третий шаг: вызов panic runtime. Управление передаётся функции __rust_start_panic, которая определена в одном из двух сменных крейтов: panic_unwind или panic_abort.

Рассмотрим каждый шаг.

Panic hook: что можно сделать перед смертью потока

Hook вызывается до начала раскрутки. Это значит, что в момент вызова hook все фреймы стека на месте, все локальные переменные живы, и можно собрать backtrace (полный стек вызовов). После начала раскрутки фреймы начнут исчезать, и информация будет потеряна.

Стандартный hook печатает сообщение в stderr:

thread 'main' panicked at src/main.rs:4:5:
something went wrong
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

Hook можно заменить:

use std::panic;

panic::set_hook(Box::new(|info| {
    // info.location() — файл и строка, где произошла паника
    let location = info.location()
        .map(|l| format!("{}:{}:{}", l.file(), l.line(), l.column()))
        .unwrap_or_else(|| "unknown".to_string());
    
    // info.payload() — сообщение паники, тип &(dyn Any + Send)
    let message = if let Some(s) = info.payload().downcast_ref::<&str>() {
        s.to_string()
    } else if let Some(s) = info.payload().downcast_ref::<String>() {
        s.clone()
    } else {
        "non-string panic payload".to_string()
    };
    
    eprintln!("[PANIC] {} at {}", message, location);
    
    // Здесь можно отправить в Sentry, Datadog, PagerDuty
    // Backtrace можно собрать через std::backtrace::Backtrace::capture()
});

Payload паники — это &(dyn Any + Send). На него нужно вешать downcast, потому что паника может быть с разными типами. panic!("строка") создаёт &str. panic!("format {}", value) создаёт String. std::panic::panic_any(42u32) создаёт u32. Чаще всего это одно из первых двух.

Если hook сам запаникует, Rust обнаружит это через счётчик паник потока и немедленно вызовет abort. Две паники одновременно — процесс умирает. Это защита от бесконечной рекурсии: hook паникует, вызывает hook, тот паникует, вызывает hook...

На практике же hook полезен только для двух вещей.

В серверных приложениях — отправка информации о панике в систему мониторинга до того, как поток умрёт. Библиотеки вроде sentry-rust подключаются именно через set_hook.

В тестах — кастомный формат вывода паник (например, JSON для парсинга CI-системой).

Восстановить стандартный hook можно через panic::take_hook():

let default_hook = panic::take_hook();
// ... ваш hook
panic::set_hook(Box::new(move |info| {
    // ваша логика
    send_to_sentry(info);
    // потом стандартный вывод
    default_hook(info);
}));

Две стратегии: unwind и abort

После хука panic runtime решает, что делать. В Rust есть два panic runtime, и какой из них используется, определяется при компиляции.

# Cargo.toml
[profile.dev]
panic = "unwind"    # по умолчанию

[profile.release]
panic = "unwind"    # по умолчанию

Можно поменять:

[profile.release]
panic = "abort"

Внутри это два отдельных крейта: panic_unwind и panic_abort. Стандартная библиотека помечена атрибутом #![needs_panic_runtime], и линкер автоматически подключает нужный крейт при сборке финального бинарника.

panic = "abort": мгновенная смерть

При панике panic_abort вызывает core::intrinsics::abort(). На x86 это компилируется в инструкцию ud2 (undefined instruction). Процессор встречает инструкцию, которую не знает, генерирует исключение, ОС доставляет процессу сигнал SIGILL, процесс завершается.

Никакой раскрутки стека и деструкторы не вызываются. Файлы, которые были открыты, не закрываются через Drop. Мьютексы не освобождаются через Drop. ОС освободит всю память процесса и закроет файловые дескрипторы, но flush буферов (запись незаписанных данных на диск) не произойдёт.

Зачем это нужно:

  • Бинарник становится меньше.

  • Компилятор оптимизирует агрессивнее.

  • Невозможна двойная паника. Если деструктор паникует во время раскрутки — это проблема, которой при abort не существует, потому что деструкторы при панике не вызываются.

В embedded и no_std abort часто единственный вариант, потому что для раскрутки нужна libunwind, которой на микроконтроллере нет.

Недостаток в том, что catch_unwind не работает. При panic = "abort" catch_unwind просто вызывает замыкание напрямую, без установки обработчика, потому что обработчик некому вызвать (раскрутки нет). Если замыкание паникует, процесс абортится.

panic = "unwind": раскрутка стека

Раскрутка стека (unwinding) — процесс, при котором runtime проходит по фреймам стека от точки паники вверх, вызывая деструкторы локальных переменных на каждом фрейме. Концепция та же, что у исключений в C++, и на большинстве платформ Rust использует тот же низкоуровневый механизм.

DWARF unwinding: как это работает на Linux

На Linux и macOS раскрутка работает через DWARF. Компилятор генерирует для каждой функции специальные таблицы, которые хранятся в секции .eh_frame ELF-бинарника. Таблицы описывают:

  • Размер фрейма стека для каждой точки в функции

  • Где сохранены callee-saved регистры (на стеке или в другом регистре)

  • Как вычислить адрес предыдущего фрейма (Canonical Frame Address, CFA)

  • Где находятся landing pad-ы (код для вызова деструкторов)

Формат таблиц — DWARF Call Frame Information. Это, по идее, байткод: набор инструкций, которые описывают, как восстановить состояние регистров для предыдущего фрейма. Runtime-библиотека libunwind интерпретирует этот байткод при раскрутке.

Двухфазная раскрутка

Процесс раскрутки состоит из двух фаз.

Первая фаза (search phase). Libunwind проходит по стеку вверх от точки паники и ищет обработчик. На каждом фрейме libunwind вызывает personality function (у Rust она своя, rust_eh_personality), которая отвечает: «у этого фрейма есть обработчик» или «у этого фрейма нет обработчика, ищи дальше».

В Rust обработчик — это либо catch_unwind, либо граница потока (std::thread::spawn автоматически оборачивает пользовательскую функцию в catch_unwind). Если обработчик не найден после обхода всего стека — abort.

Зачем нужна отдельная search phase? Чтобы убедиться, что обработчик существует, до того как начнётся cleanup. Если обработчика нет, нет смысла вызывать деструкторы — программа всё равно завершится. Сразу abort, без лишних наворотов.

Вторая фаза (cleanup phase). Libunwind снова проходит по стеку, но теперь на каждом фрейме вызывает landing pad — код, который деструктурирует локальные переменные. Landing pad генерируется компилятором для каждой функции, в которой есть локальные переменные с нетривиальными деструкторами (то есть с impl Drop).

Рассмотрим пример:

fn process() {
    let file = File::open("data.txt").unwrap();   // Drop: закроет файл
    let buffer = vec![1, 2, 3];                     // Drop: освободит память
    let name = String::from("hello");               // Drop: освободит строку
    
    do_something(&file, &buffer, &name);
    // Если do_something паникует ↑, раскрутка вызовет Drop для:
    // name (последний созданный → первый уничтоженный)
    // buffer
    // file
}

Компилятор генерирует для process() две версии кода: основной путь (normal path) и landing pad (cleanup path). Основной путь выполняется при нормальной работе. Landing pad вызывается при раскрутке и содержит вызовы Drop::drop() для всех локальных переменных в обратном порядке создания.

Даже если функция никогда не паникует, компилятор генерирует для неё landing pad и записывает CFI-таблицу в .eh_frame. Таблицы увеличивают размер бинарника, но не влияют на скорость выполнения в normal path, они лежат в отдельной секции и не попадают в кеш процессора при обычном выполнении.

При panic = "abort" компилятор не генерирует ни landing pad-ов, ни CFI-таблиц, и бинарник становится меньше и компилируется быстрее.

SEH на Windows

На Windows вместо DWARF используется SEH (Structured Exception Handling) — механизм обработки исключений, встроенный в операционную систему. Rust-паника превращается в SEH-исключение с особым кодом (0xE0434352 для Rust, если мне не изменяет память, хотя точный код может зависеть от версии).

Windows-диспетчер исключений раскручивает стек, вызывая SEH-обработчики на каждом фрейме. Принцип тот же (двухфазная раскрутка, cleanup handlers), реализация другая. SEH интегрирован в ОС и работает единообразно для C, C++ и Rust, что делает межъязыковую раскрутку на Windows теоретически проще, чем на Linux.

Функция __rust_start_panic в крейте panic_unwind выбирает платформенную реализацию: на Linux/macOS вызывает UnwindRaiseException из libunwind, на Windows использует SEH через Windows API. В обоих случаях payload паники (Box<dyn Any + Send>) передаётся платформенному механизму и позже достаётся в catch_unwind.

Еще интересно то, что перед тем как передать payload в платформенный механизм, __rust_start_panic вызывает take_box() на payload. Эта функция проверяет, не упакован ли payload уже в Box. Если да — просто берёт указатель. Если нет — боксит. Это экономит одну аллокацию в частом случае, когда payload уже является Box (например, при panic_any).

catch_unwind: как ловить панику

std::panic::catch_unwind устанавливает обработчик раскрутки. Если внутри замыкания произойдёт паника, раскрутка дойдёт до catch_unwind и остановится:

use std::panic;

let result = panic::catch_unwind(|| {
    let data = vec![1, 2, 3];
    panic!("boom");
    // data дропнется при раскрутке
});

match result {
    Ok(value) => println!("всё хорошо: {:?}", value),
    Err(payload) => {
        // payload — Box<dyn Any + Send>
        if let Some(msg) = payload.downcast_ref::<&str>() {
            println!("паника: {}", msg);
        } else if let Some(msg) = payload.downcast_ref::<String>() {
            println!("паника: {}", msg);
        } else {
            println!("паника с неизвестным типом");
        }
    }
}
// Программа продолжает работать

Между точкой паники и catch_unwind все деструкторы вызываются в обратном порядке создания. Vec дропнется, File закроется, MutexGuard освободит мьютекс. После catch_unwind программа может продолжить работу.

UnwindSafe: маркерный трейт

catch_unwind требует, чтобы замыкание реализовывало UnwindSafe. Идея в том, что после паники и перехвата программа продолжает работать, но данные, которые мутировались в момент паники, могут быть в непоследовательном состоянии.

Пример:

Вы добавляете элемент в Vec. Внутри push() Vec может реаллоцировать буфер: аллоцировать новый, скопировать элементы, освободить старый. Если паника произойдёт после аллокации нового буфера, но до копирования, Vec окажется в состоянии, которого не бывает при нормальной работе (новый буфер пуст, старый ещё жив, length не обновлён).

Поэтому &mut T по умолчанию не UnwindSafe:

let mut data = vec![1, 2, 3];

// Не компилируется: &mut Vec<i32> не UnwindSafe
let result = panic::catch_unwind(|| {
    data.push(4);
    panic!("oops");
});

Если вы уверены, что inconsistent state вообще не проблема (или готовы проверить после паники), AssertUnwindSafe обернёт что угодно:

let mut data = vec![1, 2, 3];

let result = panic::catch_unwind(panic::AssertUnwindSafe(|| {
    data.push(4);
    panic!("oops");
}));

// data может быть [1, 2, 3, 4] или [1, 2, 3]
// зависит от того, в какой момент push() была прервана
println!("data after panic: {:?}", data);

На практике для Vec push() паника может произойти только при аллокации (OOM), и если аллокация не удалась, push ничего не менял. Но для более сложных мутаций (частичное обновление HashMap, запись в несколько полей структуры) состояние после паники действительно может быть непоследовательным.

Рекомендация тут простая: используйте AssertUnwindSafe осознанно. Если после catch_unwind вы не собираетесь использовать мутированные данные (например, собираетесь вернуть ошибку вызывающему коду через FFI), AssertUnwindSafe безопасен. Если собираетесь продолжить работу с данными, подумайте, могут ли они быть испорчены.

Паника через FFI: undefined behavior и C-unwind

До Rust 1.71 паника, пересекающая FFI-границу, была безусловным UB. Если Rust-функция с extern "C" паниковала, раскрутка стека пыталась пройти через C-фреймы. C-фреймы не содержат DWARF CFI-информации (C-компилятор не генерирует её для обычных функций, только для C++ с исключениями). Libunwind не может восстановить предыдущий фрейм, и что происходит дальше — зависит от версии libunwind.

// ОПАСНО: если do_work паникует — UB
#[no_mangle]
pub extern "C" fn callback(x: i32) -> i32 {
    do_work(x)  // может паниковать
}

Правильный подход: catch_unwind на каждой FFI-границе:

use std::panic::{catch_unwind, AssertUnwindSafe};

#[no_mangle]
pub extern "C" fn callback(x: i32) -> i32 {
    match catch_unwind(AssertUnwindSafe(|| do_work(x))) {
        Ok(result) => result,
        Err(_) => -1,  // код ошибки
    }
}

C-unwind ABI (Rust 1.71+)

С Rust 1.71 появился ABI extern "C-unwind", который говорит: «раскрутка через эту FFI-границу разрешена».

#[no_mangle]
pub extern "C-unwind" fn callback(x: i32) -> i32 {
    do_work(x)  // если паникует — раскрутка пройдёт через C/C++ фреймы
}

Это нужно для взаимодействия с C++. C++ тоже использует DWARF unwinding (на Linux), и C++-фреймы содержат CFI-информацию и деструкторы. Если Rust-код вызывается из C++ через callback, C-unwind позволяет Rust-панике раскрутить C++-фреймы, вызвав C++-деструкторы по пути, и быть пойманной в C++ через catch(...).

Но вот что C-unwind не делает:

  • Не делает чистый C-код совместимым с раскруткой. Если между Rust-фреймами находятся C-фреймы, скомпилированные без -fexceptions, раскрутка через них непредсказуема.

  • Не позволяет надёжно ловить чужие исключения. Если C++ exception попадает в Rust через catch_unwind, результат — один из двух (не определено какой): abort или Err с непрозрачным типом. Даже Rust-паника из другой версии стандартной библиотеки считается «чужой».

На практике C-unwind полезен для C++-interop (C++ callbacks, которые могут выбрасывать исключения, вызываемые из Rust). Для C-interop по-прежнему нужен catch_unwind.

При panic = "abort" ABI extern "C-unwind" не меняет поведения: паника всё равно вызовет abort, потому что раскрутки нет.

Двойная паника: почему abort

Что произойдёт, если деструктор, вызванный во время раскрутки стека, сам запаникует?

struct Bomb;

impl Drop for Bomb {
    fn drop(&mut self) {
        panic!("взрыв в деструкторе");
    }
}

fn main() {
    let _bomb = Bomb;
    panic!("первая паника");
}

Первая паника начинает раскрутку. При выходе из main вызывается Drop для Bomb. Drop паникует. Rust обнаруживает, что счётчик паник потока уже равен 1 (первая паника его инкрементировала), и вызывает abort.

Двойная паника = abort. Всегда и без вариантов.

Почему не попробовать раскрутить дальше?

Потому что если раскрутка стека вызывает ещё одну раскрутку, состояние стека становится неопределённым. Какие фреймы уже обработаны? Какие деструкторы вызваны, а какие нет? Должна ли вторая паника раскрутить то, что не успела первая, или начать заново? На эти вопросы нет хорошего ответа, и вместо попытки разобраться, Rust просто завершает процесс.

C++ поступает так же: если деструктор бросает исключение во время раскрутки другого исключения, вызывается std::terminate().

Как защититься? Не паникуйте в Drop. Если деструктор может столкнуться с ошибкой, обработайте её внутри:

impl Drop for Connection {
    fn drop(&mut self) {
        if let Err(e) = self.close() {
            eprintln!("ошибка при закрытии соединения: {}", e);
            // Не паникуем. Логируем и продолжаем.
        }
    }
}

Если ошибку проигнорировать нельзя, проверьте, не идёт ли уже раскрутка:

impl Drop for Connection {
    fn drop(&mut self) {
        if std::thread::panicking() {
            // Уже в процессе раскрутки. Не усугубляем.
            let _ = self.close();
            return;
        }
        // Нормальный drop. Можно паниковать, если ситуация критичная.
        self.close().expect("не удалось закрыть соединение");
    }
}

std::thread::panicking() возвращает true, если текущий поток раскручивает стек. Это позволяет деструктору адаптировать поведение: при нормальном завершении паниковать на ошибке (fail fast), при раскрутке просто тихо логировать (не создавать двойную панику).

Цена раскрутки: zero-cost exceptions

DWARF CFI-таблицы увеличивают размер бинарника, но лежат в секции .eh_frame, которая при нормальном выполнении не загружается в кеш процессора. Landing pad — это код, но процессор не выполняет его, не загружает в instruction cache, предсказатель ветвлений его не учитывает. На performance нормального выполнения раскрутка не влияет.

Зато сама раскрутка, когда она происходит, стоит дорого. Libunwind должна:

  1. Прочитать CFI-байткод из .eh_frame (возможно, с диска, если секция не в памяти)

  2. Интерпретировать байткод для восстановления состояния регистров

  3. Вызвать personality function для каждого фрейма

  4. Вызвать landing pad (деструкторы) на каждом фрейме

Это значительно медленнее, чем раскрутка исключений в Java или C# (где JIT-компилятор оптимизирует обработку исключений).

Поэтому делаем Result для ожидаемых ошибок (файл не найден, сеть недоступна, невалидный ввод), panic! только для программных ошибок (index out of bounds, assertion failure, нарушение инварианта).

no_std: паника без стандартной библиотеки

В no_std-окружениях (ядра ОС, embedded, WASM) стандартной библиотеки нет. Нет ни panic_unwind, ни panic_abort. Нет libunwind, нет DWARF-инфраструктуры. Нужно определить собственный panic handler:

#![no_std]
#![no_main]

use core::panic::PanicInfo;

#[panic_handler]
fn panic(info: &PanicInfo) -> ! {
    // На embedded: записать в UART
    // На WASM: вызвать console.error через FFI
    // На ядре ОС: вывести на экран, остановить CPU
    
    // info содержит сообщение и location
    // но форматировать его без alloc непросто
    
    loop {
        // ! означает «никогда не возвращается»
        // На ARM: можно вызвать WFI (Wait For Interrupt)
        // для экономии энергии
    }
}

#[panic_handler] принимает &PanicInfo и возвращает ! (never type). Функция не имеет права вернуть управление. На embedded это обычно бесконечный цикл или перезагрузка через watchdog timer. В ядре ОС — kernel panic с выводом на экран и halt процессора.

В no_std деструкторы при панике не вызываются. Каждая паника — это по сути abort с пользовательским hook. Для ядра это приемлемо (ОС перезагрузится), для embedded зависит от контекста. Если ваш embedded-код должен корректно освобождать ресурсы при ошибке, используйте Result вместо panic — в no_std это единственный вариант обработки ошибок с cleanup.

Форматирование сообщения паники в no_std уже как целая отдельная проблема. core::fmt доступен, но write! в UART требует реализации core::fmt::Write для вашего UART-драйвера. Без аллокатора String использовать нельзя, только фиксированные буферы.

use core::fmt::Write;

struct UartWriter;

impl core::fmt::Write for UartWriter {
    fn write_str(&mut self, s: &str) -> core::fmt::Result {
        for byte in s.bytes() {
            unsafe { uart_send_byte(byte); }
        }
        Ok(())
    }
}

#[panic_handler]
fn panic(info: &PanicInfo) -> ! {
    let mut writer = UartWriter;
    let _ = write!(writer, "PANIC: {}\r\n", info);
    loop {}
}

Что из этого следует

Паника в Rust устроена сложнее, чем кажется на первый взгляд, и большая часть этой сложности спрятана за простым вызовом panic!().

А если когда-нибудь увидите abort с сообщением «thread panicked while panicking» — теперь вы знаете, что это двойная паника и что это не баг, а защита.

Тема большая, и я мог где-то ошибиться или упростить. Если заметили неточность или у вас есть опыт, которым хотите поделиться (особенно по FFI и C-unwind, там вообще целый океан), пишите в комментариях, будет полезно всем. Спасибо, что дочитали.


Размещайте облачную инфраструктуру и масштабируйте сервисы с надежным облачным провайдером Beget.

Эксклюзивно для читателей Хабра мы даем бонус 10% при первом пополнении.

Воспользоваться

Комментарии (0)