Раздел 23 · Rust

Unsafe Rust

lead~45 мин

открытый урокЭтот раздел читается без входа. Войди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти

Unsafe Rust

В прошлом уроке ты подписал чужой контракт, не дочитав. unsafe impl Send for Arc<T> означало: компилятор не может доказать потокобезопасность сам, поэтому за неё ручаешься ты. Сегодня разберём, что именно ты подписываешь, когда снимаешь страховку. Главная мысль урока противоположна страшилкам про unsafe: это слово не отключает borrow checker и не превращает Rust в Си.

Оно открывает ровно пять дополнительных возможностей и перекладывает на тебя несколько инвариантов, которые до этого держал компилятор. Внутри unsafe нет защиты от неопределённого поведения, и потому весь остальной язык построен так, чтобы unsafe можно было запереть в крошечную проверенную клетку, а наружу выставить безопасный API.

Парадокс, к которому мы придём: грамотный unsafe уменьшает число багов, а не плодит их, потому что собирает всю опасность в одном месте, где её видно.

Историческая справка: откуда берётся опасность

unsafe старше самого языка в его публичном виде. Он был частью Rust ещё до релиза 1.0 (май 2015) и на момент 1.0 уже держал на себе стандартную библиотеку: Vec, Box, Arc, Mutex, любой контейнер с ручным управлением памятью внутри написан через unsafe. То есть безопасный Rust это не «язык без unsafe», а тонкая проверенная прослойка поверх большого ядра из unsafe, спрятанного за безопасными интерфейсами.

Само понятие неопределённого поведения пришло из Си и C++. Там оно знаменито идиомой «демонов из носа»: в 1992 году в группе comp.std.c Джон Вудс заметил, что стандарт разрешает программе с UB делать что угодно, «вплоть до того, чтобы из твоего носа вылетели демоны». Шутка прижилась, потому что точно описывает суть: UB это не «программа упадёт здесь», а «компилятору разрешено всё». Вот классический пример на Си, который пугает именно тем, что выглядит безобидно:

int table[4];
int contains(int v) {
    for (int i = 0; i <= 4; i++)   // <= вместо <: на i==4 читаем table[4], выход за границу
        if (table[i] == v) return 1;
    return 0;
}

Чтение table[4] это UB. Компилятор рассуждает так: UB по определению не происходит, значит, цикл никогда не доходит до выхода за границу, значит, единственный способ не упереться в UB это всегда вернуть 1. И оптимизатор имеет полное право скомпилировать всю функцию в return 1;. Баг не в том, что для одного входа вернётся неверный ответ. Баг в том, что компилятор законно переписал программу. Запомни это ощущение: одно нарушение инварианта на одной ветке отравляет весь соседний код, в том числе безопасный.

Rust защищает тебя от такого ровно до границы unsafe. Внутри неё ты сам обязан не давать оптимизатору такого предположения. Чтобы понять, что именно нельзя нарушать, исследователи формализовали правила. Проект RustBelt (Юнг и Драйер, POPL 2018) впервые формально доказал в системе Coq, что безопасные обёртки вроде Arc и Mutex действительно корректны, если их unsafe-ядро держит выписанные инварианты. А чтобы проверять конкретный код, а не теоремы, придумали операционные модели алиасинга: Stacked Borrows (Юнг и др., POPL 2020) и более новую Tree Borrows (Виллани и др., PLDI 2025). Обе живут в miri, и обе мы потрогаем руками. Важная деталь на 2026 год: финальная модель алиасинга Rust ещё не зафиксирована официально, по умолчанию miri проверяет Stacked Borrows, а Tree Borrows включают флагом. Так что unsafe это область, где правила всё ещё уточняются, и тем важнее держаться известной их части.

Что unsafe разрешает и чего НЕ отключает

Развеем главный миф сразу. unsafe внутри блока или функции не выключает проверку заимствований, проверку типов и контроль времён жизни. Всё это работает как обычно. unsafe разрешает ровно пять вещей, которые в безопасном Rust запрещены, их так и называют «пять суперспособностей»:

  1. разыменовать сырой указатель (*const T, *mut T);
  2. вызвать unsafe-функцию или метод;
  3. обратиться к изменяемой статической переменной (static mut);
  4. реализовать unsafe-трейт (тот самый unsafe impl Send);
  5. прочитать поле union.

И всё. Внутри unsafe ты по-прежнему не можешь сделать два &mut на одну переменную через безопасные ссылки, перепутать типы или вернуть ссылку на локальную переменную: эти ошибки ловятся как всегда. unsafe это не «теперь можно нарушать правила», а «теперь можно сделать пять конкретных операций, корректность которых компилятор проверить не в силах, поэтому проверяешь ты».

fn main() {
    let mut x = 10;
    let r = &mut x as *mut i32; // создать сырой указатель безопасно, это просто адрес

    unsafe {
        *r = 20; // а вот разыменовать его можно только в unsafe: суперспособность №1
    }

    println!("{x}"); // 20
}

Обрати внимание: создание сырого указателя &mut x as *mut i32 стоит вне unsafe. Опасно не получить адрес, опасно по нему пойти. Граница unsafe проводится ровно там, где компилятор перестаёт давать гарантии: в момент разыменования.

Сырые указатели

Сырой указатель это адрес без обязательств. В Rust их два вида: *const T и *mut T. От ссылок они отличаются тем, чего у них нет:

  • у них нет времени жизни, компилятор не следит, жива ли ещё память под ними;
  • они не подчиняются правилам заимствования, можно держать сколько угодно *mut T на одну ячейку;
  • они могут быть null, висячими (указывать на освобождённое) или невыровненными;
  • они не получают Send/Sync автоматически, потому что не дают никаких гарантий.
fn main() {
    let value = 42;
    let p1 = &value as *const i32; // из ссылки
    let p2 = p1;                   // сырые указатели Copy, копируй сколько хочешь
    let p3 = 0x1234 as *const i32; // даже из произвольного числа, это законно

    unsafe {
        println!("{}", *p1); // 42, p1 валиден
        println!("{}", *p2); // 42, тот же адрес
        // println!("{}", *p3); // почти наверняка UB: по адресу 0x1234 ничего нашего нет
    }
}

*const и *mut различаются только намерением и свободно кастуются друг в друга. Сами по себе они почти бесполезны, их смысл в том, чтобы строить структуры, которые безопасные ссылки выразить не могут: связные списки с указателем на хвост, разделяемые буферы, мосты к Си. Каждый раз, когда ты пишешь *p, ты обещаешь компилятору, что по этому адресу лежит валидное, инициализированное, выровненное значение нужного типа, и что ты не нарушаешь правил алиасинга. Ниже разберём каждое из этих обещаний.

Неопределённое поведение: список того, чего нельзя

UB это не «программа упадёт». Иногда она падает, иногда выдаёт мусор, иногда работает годами, а потом ломается от смены версии компилятора или флага оптимизации, потому что оптимизатор переписал код, опираясь на нарушенный тобой инвариант. Поэтому к списку UB относятся не как к «постарайся избегать», а как к «не нарушай никогда, даже на ветке, которая кажется недостижимой».

Канонический список зафиксирован в разделе reference про behavior considered undefined. Главное из него:

  • гонка данных: два потока обращаются к одной памяти без синхронизации, и хотя бы один пишет;
  • разыменование висячего, невыровненного или null указателя;
  • нарушение правил алиасинга (про них отдельный раздел ниже);
  • создание невалидного значения: bool, который не 0 и не 1; enum с несуществующим дискриминантом; ссылка, равная null; чтение неинициализированной памяти в тип, который этого не допускает (а почти никакой не допускает);
  • вызов функции с неправильным ABI или разворачивание паники через границу, которая этого не позволяет;
  • мутация байтов, помеченных как неизменяемые, вне UnsafeCell.

Ключевое и контринтуитивное: даже просто создать невалидное значение это уже UB, использовать его не обязательно. let b: bool = unsafe { mem::transmute(2u8) }; это UB в момент присваивания, потому что компилятор повсюду полагается на то, что любой bool равен 0 или 1, и вправе скомпилировать if b в код, который для b == 2 делает что попало. Это прямое следствие истории с table[4]: оптимизатор рассуждает глобально, и одно невалидное значение развязывает ему руки далеко за пределами строки, где ты сошёл с рельсов.

Правила алиасинга и модель Stacked Borrows

Самое тонкое обещание из всех. Rust строит свои оптимизации на двух правилах про ссылки:

  • пока жива общая ссылка &T, память под ней никто не меняет (кроме как внутри UnsafeCell);
  • пока жива эксклюзивная ссылка &mut T, к этой памяти не обращается никакой другой указатель, не происходящий от неё.

Безопасный Rust держит эти правила автоматически. В unsafe ты можешь их нарушить сырыми указателями, и тогда оптимизатор, поверивший в эксклюзивность &mut, начнёт выдавать неверный код. Чтобы превратить «правила алиасинга» из философии в проверяемый алгоритм, и придумали Stacked Borrows.

Модель смотрит на каждую ячейку памяти как на стопку тегов. У каждого указателя свой уникальный тег. Создание ссылки это reborrow: новый тег кладётся на вершину стопки. Использование указателя требует, чтобы его тег ещё лежал в стопке, и снимает с неё всё, что лежит выше. А доступ по тегу, которого в стопке уже нет, это UB. Вся дисциплина &mut сводится к одному правилу про стопку: пока работаешь с потомком, родитель ждёт под ним, а как только обратился к родителю, потомки слетают и больше недействительны.

Покрути сценарии в стенде. Вкладка «стек соблюдён» показывает честное вложенное использование, вкладка «доступ после снятия» это классическое нарушение: вернулись к снятому тегу, и miri ловит это сразу.

Tree Borrows, новая модель с PLDI 2025, заменяет стопку на дерево, повторяющее реальную иерархию reborrow, и отслеживает состояние прав в каждом узле. Она мягче: пропускает корректный код, который Stacked Borrows ошибочно браковал, и при этом ловит настоящие нарушения. Но в miri она пока опциональна (флаг -Zmiri-tree-borrows), по умолчанию работает Stacked Borrows. Для практики держи в голове именно стопочную картинку: она строже и потому безопаснее как ориентир.

MaybeUninit: работа с ещё не инициализированной памятью

Иногда сырой указатель ведёт в память, в которой ещё ничего нет: ты выделил буфер и собираешься заполнять его по элементу. Наивное «дай мне значение из ниоткуда» в старом Rust выглядело как mem::uninitialized(), и это была ловушка. Функцию признали безнадёжной и убрали из обихода: она эквивалентна попытке выдать значение типа T из неинициализированных байтов, а это, как мы только что видели, мгновенное UB почти для любого T, даже для i32, ведь «целое должно быть инициализировано» само по себе входит в правила валидности.

Замена это MaybeUninit<T>, обёртка, которой разрешено держать неинициализированные байты, потому что сама она не считается значением T. Ты создаёшь её пустой, заполняешь, и только когда всё готово, превращаешь в настоящий T вызовом assume_init:

use std::mem::MaybeUninit;

fn make_pair() -> [u32; 2] {
    let mut buf: [MaybeUninit<u32>; 2] = [MaybeUninit::uninit(), MaybeUninit::uninit()];

    buf[0].write(1); // пишем по элементу, пока это не валидный [u32; 2]
    buf[1].write(2);

    // обещаем: оба элемента инициализированы. Соврёшь, и assume_init это UB.
    unsafe { std::mem::transmute::<_, [u32; 2]>(buf) }
}

Контракт assume_init целиком на тебе: вызвать его, пока хоть один байт не записан, это UB. MaybeUninit не убирает опасность, он даёт ей легальное место, где неинициализированные байты не нарушают правил валидности, и чёткую точку, в которой ты под свою ответственность объявляешь память готовой.

Контракт безопасной обёртки

Теперь главная инженерная идея урока, ради которой всё затевалось. unsafe опасен не сам по себе, а когда размазан по коду. Правильная техника называется безопасная абстракция: весь unsafe запирается внутри типа и поддерживает инвариант, а наружу торчит только безопасный API, которым этот инвариант нельзя сломать. Снаружи человек пишет обычный безопасный Rust и физически не может вызвать UB, потому что все опасные операции спрятаны за проверенной границей.

Канонический пример это очередь на связном списке. Внутри она держит сырой указатель на хвост, чтобы push_back за один шаг дотягивался до конца. Безопасные ссылки так не умеют: нельзя одновременно владеть цепочкой через голову и держать &mut на её хвост, это два пересекающихся эксклюзивных доступа. Поэтому хвост хранят сырым указателем, и весь unsafe живёт в двух методах. Инвариант один: хвостовой указатель валиден ровно тогда, когда очередь не пуста.

Покрути обе вкладки. «Очередь работает» показывает, как инвариант держится. «Висячий tail» показывает, что бывает, если в pop_front забыть вернуть хвост в null, когда очередь опустела: сырой указатель переживает данные, и следующий push_back пишет в освобождённую память.

Вот тот же скелет в коде. Заметь, что весь unsafe локализован, а сигнатуры push_back и pop_front совершенно безопасны:

pub struct Queue<T> {
    head: Option<Box<Node<T>>>,
    tail: *mut Node<T>, // сырой указатель на конец, тут вся опасность
}

struct Node<T> {
    elem: T,
    next: Option<Box<Node<T>>>,
}

impl<T> Queue<T> {
    pub fn new() -> Self {
        Queue { head: None, tail: std::ptr::null_mut() }
    }

    pub fn push_back(&mut self, elem: T) { // безопасная сигнатура
        let mut node = Box::new(Node { elem, next: None });
        let raw: *mut Node<T> = &mut *node;
        if self.tail.is_null() {
            self.head = Some(node);
        } else {
            unsafe { (*self.tail).next = Some(node); } // инвариант: tail валиден, ведь не null
        }
        self.tail = raw;
    }

    pub fn pop_front(&mut self) -> Option<T> { // безопасная сигнатура
        self.head.take().map(|node| {
            self.head = node.next;
            if self.head.is_none() {
                self.tail = std::ptr::null_mut(); // та самая строчка, держащая инвариант
            }
            node.elem
        })
    }
}

Это и есть ответ на парадокс из вступления. unsafe уменьшает число багов, когда заперт в десяти строках с явным инвариантом: проверить нужно только эти десять строк, а весь код, который пользуется очередью, безопасен по построению. Целая глава «Entirely Too Many Linked Lists» построена вокруг этой техники, и не зря: связные структуры в Rust это лучший полигон для безопасных абстракций над сырыми указателями.

miri: интерпретатор, ловящий UB на тестах

Проблема UB в том, что обычный запуск его часто не показывает. Висячий хвост из очереди в release-сборке может годами писать в память, которую ещё не переиспользовали, и ничего не падает. Нужен инструмент, который проверяет не «упало или нет», а «нарушен ли инвариант».

Это miri, интерпретатор промежуточного представления Rust. Он исполняет программу по шагам и на каждом обращении к памяти сверяется с моделью: не вышел ли ты за границу, не пишешь ли в освобождённое, выровнен ли доступ, не читаешь ли неинициализированное, не нарушил ли стопку тегов из Stacked Borrows. Запускают его на тестах:

$ cargo +nightly miri test

Если в очереди забыть про self.tail = null_mut(), обычный тест может пройти, а miri остановится на записи через висячий хвост и покажет, где именно нарушен инвариант. У miri есть честное ограничение: он проверяет только те пути, которые выполнили твои тесты. Это не доказательство корректности, а очень дотошный исполнитель. Но для unsafe это ближайшее к страховке, что у тебя есть. Правило одно: любой unsafe-код покрывай тестами и гоняй их под miri.

Provenance: почему указатель это не просто число

Копнём чуть глубже, потому что это объясняет, почему usize as *const T опасен сильнее, чем кажется. Указатель в современной модели Rust это не только адрес, но и provenance, происхождение: к какому участку памяти он вправе обращаться, как долго и на чтение или запись. Два указателя с одинаковым адресом могут иметь разную provenance, и через «чужую» лезть нельзя. Когда ты кастуешь указатель в usize, происхождение теряется, а обратный каст usize as *const T не знает, какую provenance взять. Оптимизатор перестаёт понимать, что с чем не пересекается, а miri обоснованно ругается.

Чтобы манипулировать адресом, не теряя происхождения, в Rust 1.84 (январь 2025) стабилизировали строгий API provenance: addr достаёт чистый адрес, with_addr переносит происхождение существующего указателя на новый адрес, expose_provenance и with_exposed_provenance выражают старый стиль каста явно. Тебе это нужно знать не для ежедневного кода, а чтобы понимать суть: указатель богаче своего числового адреса, и unsafe-арифметика с адресами это работа не с числами, а с правами доступа. Подробности у Ральфа Юнга в серии «Pointers Are Complicated».

FFI: граница, где гарантии Rust заканчиваются

Последняя территория unsafe это вызов чужого кода. FFI, вызов функций на Си, всегда unsafe: компилятор не видит ни их тела, ни их контракта. На этой границе на тебя ложится всё сразу, правильный ABI, валидность указателей в обе стороны, инициализированность, отсутствие гонок, согласованное представление памяти через repr(C).

use std::os::raw::c_int;

extern "C" {
    fn abs(input: c_int) -> c_int; // объявление из libc, тела Rust не видит
}

fn main() {
    let n: c_int = -42;
    let result = unsafe { abs(n) }; // вызов только в unsafe: за ABI ручаешься ты
    println!("|{n}| = {result}");
}

Сигнатуру abs подтверждаешь ты: если соврёшь про типы аргументов или ABI, получишь UB на ровном месте. Когда функция возвращает указатель, ты отвечаешь за его валидность, выравнивание и за то, кто и когда освободит память. FFI это место, где безопасный Rust буквально кончается и начинается территория, на которой ты держишь те же инварианты, что держал бы в чистом Си. Поэтому и здесь работает та же стратегия: оберни сырой FFI в безопасный модуль с проверенным API, чтобы наружу не торчало ни одного unsafe.

Жизнь до main: линкер-секции и конструкторы

Ещё одна территория unsafe лежит до того, как стартовал main. Атрибут #[link_section = "..."] кладёт переменную в именованную область бинарника, а линкер сам создаёт граничные символы __start_имя и __stop_имя, по которым программа читает всё, что насовали в секцию разные модули. На этом стоит приём распределённой регистрации: разные части программы записывают себя в общий реестр, не зная друг про друга. Но секция это сырые байты, порядок инициализации тонкий, а изменяемые данные до main требуют UnsafeCell, поэтому без unsafe тут не обойтись. Руками такое почти не пишут: крейты ctor, inventory и linkme прячут опасность за безопасным API. Полный разбор приёма и его цены лежит в уроке про внедрение зависимостей: он показывает, зачем вообще выполнять код до main и где это окупается.

Правило урока

Свернём всё в одну мысль.

unsafe не отключает borrow checker и проверку типов, он лишь открывает пять операций (разыменовать сырой указатель, вызвать unsafe-функцию, тронуть static mut, реализовать unsafe-трейт, прочитать поле union) и перекладывает их инварианты на тебя. Сырые указатели *const T и *mut T это адреса без времени жизни и без правил заимствования, опасно не создать их, а разыменовать. Неопределённое поведение это не «упадёт здесь», а разрешение оптимизатору переписать соседний код, поэтому список UB (гонки, висячий или невыровненный доступ, нарушение алиасинга, невалидное значение) не нарушают никогда, даже создавать невалидное значение нельзя. Правила алиасинга формализует Stacked Borrows: стопка тегов на ячейку, доступ по снятому тегу это UB. Неинициализированную память держат в MaybeUninit, а assume_init зовут под свою ответственность. Главная техника это безопасная абстракция: запри unsafe в крошечный модуль с явным инвариантом и выставь наружу безопасный API, тогда проверять нужно только этот модуль, и unsafe уменьшает число багов вместо того, чтобы плодить их. Покрывай его тестами и гоняй под miri.

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

ДЗ

Дальше

Ты разобрал, что подписываешь, снимая страховку: пять суперспособностей, сырые указатели без обязательств, список UB, который нельзя нарушать даже мысленно, стопку тегов Stacked Borrows как формальное правило алиасинга, MaybeUninit для неинициализированной памяти, miri как дотошного контролёра и FFI как край, за которым гарантии кончаются. Но главное, что ты унёс, это не страх, а техника: запри unsafe в маленькую проверенную клетку с явным инвариантом, и опасный код станет источником безопасных абстракций. Дальше мы вернёмся в безопасный, но выразительный Rust: в следующем уроке разберём продвинутые дженерики и границы высшего порядка (HRTB), те самые for<'a>, что позволяют писать обобщённый код над функциями, работающими с любым временем жизни.