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

17. Unsafe Rust

unsafe fn/block/trait, raw pointers, UB, работа с памятью напрямую, unsafe-инварианты, FFI-основы, Miri.

Что на самом деле разрешает unsafe

Ключевое заблуждение новичков — думать, что unsafe "отключает borrow checker" или проверки типов. Это не так: borrow checker, проверка времён жизни и вся система типов работают внутри unsafe-блока точно так же, как и снаружи. Ключевое слово unsafe открывает доступ ровно к пяти операциям, которые компилятор не может статически доказать безопасными:

  • Разыменование сырого указателя (*const T / *mut T)
  • Вызов unsafe fn, включая FFI-функции
  • Чтение или запись изменяемой статической переменной (static mut)
  • Реализация unsafe trait
  • Доступ к полям union
fn main() {
    let x = 5;
    let raw: *const i32 = &x as *const i32; // создание raw pointer — safe

    unsafe {
        println!("{}", *raw); // разыменование — требует unsafe
    }
}

Всё остальное — вызов обычных функций, создание (но не разыменование) сырых указателей, работа с generics и трейтами — остаётся safe-кодом внутри unsafe-блока, и компилятор продолжает проверять заимствования и типы.

🔑 unsafe — это контракт, а не выключатель: ключевое слово говорит компилятору "я проверил вручную то, что ты не можешь проверить", а не "перестань меня проверять".

Undefined Behavior — что это и почему это страшно

Undefined Behavior (UB) — это ситуация, для которой спецификация языка не определяет поведение вообще. Компилятор вправе предполагать, что UB никогда не происходит, и агрессивно оптимизировать код на основе этого допущения — итоговое поведение может быть непредсказуемым: от "случайно сработало" до segfault, повреждения памяти или удаления кода, который выглядел рабочим.

fn dangling_example() -> *const i32 {
    let x = 42;
    &x as *const i32 // x умирает в конце функции
} // возвращаем dangling pointer — сам факт возврата ещё не UB

fn main() {
    let p = dangling_example();
    unsafe {
        println!("{}", *p); // UB: разыменование dangling pointer
    }
}

Классические источники UB в Rust:

  • Разыменование null / dangling / невыровненного указателя
  • Создание двух &mut T на один объект одновременно (нарушение aliasing-модели)
  • Чтение неинициализированной памяти как валидного значения типа (например, bool со значением байта, отличным от 0/1)
  • Выход за границы массива через сырые указатели (в отличие от slice[i], который паникует безопасно)
  • Data race — одновременный доступ из разных потоков без синхронизации, где хотя бы один — запись
  • Нарушение объявленного #[repr] или инвариантов, которые компилятор использует для оптимизаций (например, NonNull, у которого 0 — невалидное значение)
🚫 UB — это не "просто баг": в отличие от паники или ошибки типа "неверный результат", UB даёт компилятору право на любое поведение, включая то, что код "как будто работает" на одной версии компилятора и падает на другой, или что оптимизатор удалит проверку, которая выглядела необходимой, потому что формально предполагал: "сюда мы не попадём, ведь это UB".

Raw pointers — *const T и *mut T

Сырые указатели в отличие от ссылок (&T/&mut T) не проверяются borrow checker'ом: их можно копировать, приводить друг к другу, они могут быть null, dangling, невыровненными — и всё это компилируется без единого предупреждения, пока вы их не разыменуете.

fn main() {
    let mut value = 10;

    let r1 = &mut value as *mut i32;
    let r2 = r1; // копирование указателя — safe, это просто число

    unsafe {
        *r1 += 1;
        *r2 += 1; // два "алиасящих" mut-указателя — компилятор это разрешает,
                     // но программист обязан гарантировать отсутствие data race
        println!("{value}"); // 12
    }

    let null_ptr: *const i32 = std::ptr::null();
    println!("{}", null_ptr.is_null()); // true, проверка — safe
}
Свойство&T / &mut T*const T / *mut T
Проверка borrow checker'омда, статическинет
Гарантия ненулевого значенияданет, может быть null
Гарантия валидности (не dangling)да, через lifetimeнет, ответственность программиста
Разыменованиебез unsafeтолько внутри unsafe
Aliasing (несколько mut одновременно)запрещено компиляторомразрешено, но UB при одновременном использовании

Инварианты unsafe-функций

Каждая unsafe fn — это неявный контракт: вызывающий код обязан обеспечить набор условий (preconditions), которые компилятор проверить не может, но которые функция считает истинными. Хорошая практика — документировать их в секции /// # Safety.

/// Читает значение по указателю `ptr`.
///
/// # Safety
///
/// Вызывающий обязан гарантировать:
/// - `ptr` указывает на инициализированное значение типа `T`;
/// - `ptr` корректно выровнен (aligned) под `T`;
/// - память по `ptr` валидна как минимум на время вызова;
/// - никакой другой поток не пишет по этому адресу одновременно.
unsafe fn read_unchecked<T>(ptr: *const T) -> T {
    // SAFETY: инварианты гарантирует вызывающий, см. doc выше
    unsafe { ptr.read() }
}

Комментарий // SAFETY: ... непосредственно перед unsafe-операцией — общепринятая конвенция в экосистеме (её требует clippy-lint undocumented_unsafe_blocks и стандартная библиотека сама следует этому правилу): он объясняет, почему именно в этом месте вызова инварианты выполнены.

⚠️ unsafe не локален: нарушение инварианта в одном месте программы может привести к UB, которое проявится совершенно в другом — например, повреждённая куча вызовет краш в аллокаторе спустя тысячи не связанных операций. Поэтому safe-обёртки вокруг unsafe-кода обязаны сами гарантировать инварианты для любого safe-вызова, не перекладывая ответственность на пользователя обёртки.

Building safe abstractions поверх unsafe

Идиоматичный Rust-паттерн — спрятать unsafe-код за safe API, который по построению не может нарушить инварианты. Классический пример из стандартной библиотеки — split_at_mut: borrow checker не может доказать, что две непересекающиеся части одного слайса можно одновременно заимствовать как &mut, поэтому внутри используется unsafe, а наружу торчит безопасная сигнатура.

fn split_at_mut_demo<T>(slice: &mut [T], mid: usize) -> (&mut [T], &mut [T]) {
    let len = slice.len();
    let ptr = slice.as_mut_ptr();
    assert!(mid <= len);

    // SAFETY: [0, mid) и [mid, len) не пересекаются,
    // ptr валиден на len элементов по инварианту &mut [T]
    unsafe {
        (
            std::slice::from_raw_parts_mut(ptr, mid),
            std::slice::from_raw_parts_mut(ptr.add(mid), len - mid),
        )
    }
}

Снаружи функция полностью safe: её нельзя вызвать так, чтобы получить UB, потому что все опасные допущения проверены внутри (границы, отсутствие пересечения). Это и есть суть "zero-cost", безопасной абстракции — работа компилятора и рантайм-проверок сделана один раз, в одном месте.

FFI-основы: вызов C-функций

Любой вызов внешней функции — потенциальный источник UB, поскольку компилятор не может проверить инварианты чужого кода. Блок extern "C" объявляет сигнатуры, а сам вызов всегда требует unsafe.

unsafe extern "C" {
    fn abs(input: i32) -> i32;
}

#[unsafe(no_mangle)]
pub extern "C" fn rust_double(x: i32) -> i32 {
    x * 2 // экспорт функции с C-совместимым ABI, вызывается safe-кодом на стороне C
}

fn main() {
    let result = unsafe { abs(-5) };
    assert_eq!(result, 5);
}

Ключевые нюансы FFI: типы должны быть #[repr(C)] для предсказуемой раскладки, паника не должна пересекать границу FFI (в C нет механизма unwinding — используйте catch_unwind или extern "C-unwind"), а владение памятью между Rust и C должно быть чётко определено (кто аллоцирует — тот и освобождает).

static mut и unsafe trait

Изменяемые статические переменные — ещё один источник потенциальных data race, поэтому любой доступ к ним требует unsafe. С Rust 2024 edition сам факт объявления static mut уже помечается как небезопасный паттерн, а получение ссылки на него — отдельная unsafe-операция.

static mut COUNTER: u32 = 0;

fn increment() {
    // SAFETY: вызывается только из одного потока в этом примере;
    // в многопоточном коде здесь нужен AtomicU32, а не static mut
    unsafe {
        COUNTER += 1;
    }
}

На практике static mut почти всегда стоит заменить на атомики (AtomicU32, AtomicUsize) или Mutex/OnceLock — они дают ту же функциональность без ручного unsafe и без риска data race.

unsafe trait используется, когда сам факт реализации трейта несёт инвариант, который компилятор проверить не может — классический пример: Send и Sync для типа с сырыми указателями внутри.

struct SharedBuffer {
    ptr: *mut u8,
    len: usize,
}

// SAFETY: доступ к ptr синхронизируется извне мьютексом,
// поэтому одновременная передача между потоками безопасна
unsafe impl Send for SharedBuffer {}
unsafe impl Sync for SharedBuffer {}

Miri — интерпретатор для поиска UB

Miri — это MIR-интерпретатор, входящий в nightly-инструментарий Rust, который выполняет программу не как нативный код, а пошагово интерпретируя MIR, отслеживая при этом состояние памяти, инициализацию, выравнивание и aliasing-модель (Stacked/Tree Borrows). Многие виды UB, которые незаметны при обычном запуске (потому что "просто повезло"), Miri обнаруживает детерминированно.

$ rustup +nightly component add miri
$ cargo +nightly miri test
$ cargo +nightly miri run
fn main() {
    let mut data = [1, 2, 3];
    let ptr = data.as_mut_ptr();

    unsafe {
        let out_of_bounds = ptr.add(10);
        *out_of_bounds = 42; // UB: запись за границами массива
        // нативный запуск может "случайно сработать",
        // Miri гарантированно сообщит об ошибке
    }
}

Miri находит: чтение неинициализированной памяти, use-after-free, выход за границы через сырые указатели, нарушение aliasing-правил для &mut, утечки памяти, некорректное выравнивание. Он значительно медленнее нативного запуска (интерпретация MIR), поэтому применяется в тестах, а не в продакшене.

✅ Правило для unsafe-кода: любой крейт, содержащий нетривиальный unsafe (кастомные структуры данных, FFI-обёртки, аллокаторы), должен гонять свой test suite под cargo miri test в CI — это дешёвый способ поймать UB до того, как он проявится непредсказуемым багом у пользователя на другой платформе или версии компилятора.