Если взять случайный крейт с crates.io, поставить на него Miri и подождать минут пять, шанс увидеть красное сообщение про undefined behavior где-то в зависимостях стремится к единице. Чаще всего виноват не автор хитрого unsafe-блока ради скорости, а вполне обычная библиотека, которой пять лет, у которой звёзд на гитхабе больше, чем у твоего пет-проекта строчек кода, и которая всё это время спокойно лежит в продакшене.

Самое неприятное в этой истории то, что компилятор ничего не скажет. Тесты пройдут. Бенчмарки покажут красивые наносекунды. А потом LLVM 19 обновится до LLVM 20, поменяет один проход оптимизации, и твой сервис начнёт ронять прод по понедельникам. Чтобы понять, почему так происходит, придётся залезть в три темы, которые в обычной жизни Rust-разработчика не встречаются: pointer provenance, Stacked Borrows и пришедшую им на смену Tree Borrows.

Указатель это не адрес

Самое вредное заблуждение, которое тащится в Rust из C, звучит так: указатель это число, представляющее адрес в памяти. С точки зрения процессора так и есть. С точки зрения компилятора уже лет двадцать как нет, просто в C про это стесняются говорить вслух, а в Rust решили оформить явно.

У каждого указателя в модели Rust помимо адреса есть невидимый ярлык, называется provenance. Это по сути идентификатор аллокации, из которой указатель родился, плюс набор разрешений на доступ. Когда ты делаешь Box::new(42), рантайм возвращает тебе не просто адрес, а адрес плюс свежий ярлык, привязанный именно к этому боксу. Когда ты кастуешь &mut T в *mut T, ярлык наследуется. Когда ты складываешь два числа и пытаешься объявить результат указателем через transmute, ярлыка у получившейся штуки нет, и любое разыменование такого указателя является UB, даже если по битам адрес совпадает с тем, что вернул аллокатор пять строк назад.

Проверить это руками просто:

fn main() {
    let a = [1u8, 2, 3, 4];
    let b = [5u8, 6, 7, 8];
    let pa = a.as_ptr();
    let pb = b.as_ptr();
    let offset = pb as isize - pa as isize;
    let forged = unsafe { pa.offset(offset) };
    unsafe { println!("{}", *forged); }
}

На любом нормальном процессоре forged будет указывать ровно туда же, куда pb. Программа что-то напечатает. Скорее всего пятёрку. Запусти под Miri, и увидишь сообщение про trying to retag from a pointer that was not derived from this allocation. С точки зрения железа всё корректно, с точки зрения модели памяти Rust ты использовал ярлык от массива a, чтобы залезть в массив b, а это запрещено независимо от того, что числа сошлись.

Смысл провенанса в том, чтобы компилятор мог делать алиасинг-анализ без ограничений языка C. В C спецификация говорит: если два указателя на разные объекты, они не алиасят, кроме случаев. Случаев много, они громоздкие, и restrict помогает редко. В Rust спецификация говорит: ярлыки разные, значит указатели не алиасят, точка. Это позволяет помечать почти все ссылки атрибутом noalias на уровне LLVM, и оптимизатор имеет право переставлять загрузки и записи как ему удобно.

Stacked Borrows как формальная игра в реборроу

Долгое время не существовало вообще никакой формальной модели, описывающей, какой код Rust считается корректным на уровне unsafe. Команда rustc писала компилятор, опираясь на интуицию, LLVM делал оптимизации по своим правилам, а пользователи писали unsafe-блоки, надеясь, что оба этих мира между собой договорятся. В 2018 году Ральф Юнг предложил Stacked Borrows, и с этого момента у Rust появилась хотя бы рабочая теория, которую можно проверять на машине.

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

Из этой простой механики вытекает почти вся семантика заимствований, включая ту, которую ты привык получать от borrow checker, плюс куча правил для unsafe, которых borrow checker не видит. Например, классика:

fn main() {
    let mut x = 42;
    let r1 = &mut x;
    let raw = r1 as *mut i32;
    let r2 = &mut *r1;
    *r2 = 7;
    unsafe { *raw = 13; }
    println!("{}", x);
}

Borrow checker этот код пропускает: raw это сырой указатель, его время жизни никого не волнует, r2 валидный реборроу от r1. Программа компилируется, запускается, печатает 13. Под Miri ловится UB. Объяснение в терминах стека: создание r2 положило новый тег поверх тега raw, поэтому, когда ты пишешь через raw, его тег уже не на вершине, а правила Stacked Borrows требуют для записи, чтобы тег был в стеке и выше него ничего не лежало.

Любимый сценарий, в котором это превращается в баг прода, это передача сырого указателя в C-библиотеку. Ты честно создаёшь &mut, кастуешь, передаёшь. Где-то рядом другой &mut от того же объекта живёт в Rust-коде, и компилятор имеет полное право предположить, что C через сырой указатель туда не полезет, потому что ярлык не тот. Ассемблер, сгенерированный из таких предположений, начинает кешировать значения в регистрах, и привет.

Почему пришлось выключать noalias и причём тут LLVM

В районе 2014 года rustc впервые включил noalias для &mut ссылок. Через несколько недель выяснилось, что в LLVM есть баги, которые срабатывают только на коде с большим количеством noalias-параметров, потому что C-компиляторы такой код почти не генерируют. Атрибут пришлось выключить. Включили обратно через пару лет, поймали следующий слой багов, выключили снова. Эта история с переменным успехом продолжалась примерно до 2021 года, и каждый раз rustc находил в LLVM что-то новое, что Clang за двадцать лет не нашёл, потому что человек на C просто не пишет в стиле «двадцать ссылок без алиасинга подряд».

Побочный эффект для всего сишного мира получился приятный: те же оптимизации заработали корректнее в Clang, и restrict стало можно использовать без страха, что компилятор споткнётся. Rust здесь сыграл роль адского стенда нагрузочного тестирования для алиасинг-анализа в LLVM, и до сих пор играет.

Tree Borrows и почему Stacked Borrows оказалось слишком строгим

Stacked Borrows красивая модель, но у неё есть проблема. Она запрещает паттерны, которые широко встречаются в реальном unsafe-коде и при этом интуитивно выглядят корректными. Самый болезненный пример это interior mutability через сырые указатели и работа со стандартными коллекциями.

use std::cell::Cell;

fn main() {
    let c = Cell::new(0i32);
    let p = &c as *const Cell<i32> as *mut i32;
    unsafe { *p = 1; }
    println!("{}", c.get());
}

По интуиции код корректен: Cell для того и существует, чтобы внутрь можно было писать через общую ссылку. По Stacked Borrows здесь нюансы с тем, как именно был получен p, и разные версии Miri разные версии этого кода ругают по-разному. Накопилась библиотека таких пограничных случаев, и стало понятно, что модель надо чинить.

Tree Borrows это работа Невена Вильмена, защищённая в 2023 году. Вместо стека тегов на каждый байт строится дерево, корнем которого является исходная аллокация, а детьми идут все производные ссылки и указатели. У каждого узла есть состояние: Reserved, Active, Frozen, Disabled. Переходы между состояниями описывают, что разрешено делать с памятью через данный тег, и что происходит с потомками, когда родителя используют для чтения или записи.

Эта модель строго слабее Stacked Borrows на куче существующих unsafe-паттернов и одновременно строго сильнее на тех местах, где Stacked Borrows был дырявым. Самое практичное следствие: код вокруг UnsafeCell, который раньше ругался в Miri без явной вины автора, под Tree Borrows проходит. Включить в Miri можно через MIRIFLAGS=-Zmiri-tree-borrows.

Что с этим делать сегодня

Если ты пишешь только safe Rust и не используешь unsafe, можно условно расслабиться. Условно потому, что любая твоя зависимость, у которой внутри есть unsafe impl Send или ручная работа с указателями, может протащить UB к тебе. Поэтому простой совет звучит так: раз в спринт прогоняй тесты под Miri и Tree Borrows.

cargo +nightly miri test
MIRIFLAGS="-Zmiri-tree-borrows" cargo +nightly miri test

Если тесты под Miri идут на порядок дольше обычных, это нормально, Miri это интерпретатор, а не нативный код. Но они находят такое, что не находит ни санитайзер, ни fuzz, ни ручной ревью.

Для авторов библиотек есть пара более жёстких правил, которые стоит держать в голове. Никогда не получай ссылку и сырой указатель из одного и того же &mut так, чтобы они оба пережили друг друга. Если нужен сырой указатель, который будет жить долго, бери его до создания всех ссылок и не делай реборроу. Используй UnsafeCell для всего, во что планируешь писать через общий доступ, даже если кажется, что рядом нет ни одной ссылки. И не пытайся восстановить указатель из числа через as, если у тебя нет провенанса под рукой, для этого с недавних пор есть with_exposed_provenance, и это не косметика, а единственный задокументированный способ сделать такое без UB.

Финальная мысль

Главное, что меняется в голове после знакомства с провенансом и Tree Borrows, это отношение к unsafe. Unsafe в Rust часто подаётся как «здесь компилятор тебе верит». На самом деле всё наоборот: в unsafe компилятор тебе верит ровно по тем правилам, которые описаны в модели памяти, и эти правила строже, чем в C. Цена этой строгости агрессивные оптимизации, которых нет нигде больше. Каждый раз, когда твой бенчмарк показывает, что Rust на десять процентов быстрее аналогичного C, где-то под капотом сработал noalias, который смог сработать только потому, что Stacked или Tree Borrows гарантировал отсутствие алиасинга.

Так что когда видишь в выводе Miri незнакомое слово retag, не закрывай терминал. Это компилятор показывает, что между твоим представлением о памяти и его представлением начали расходиться чертежи, и через пару релизов LLVM это разойдение станет видно невооружённым глазом, прямо в проде.


Люблю Rust, пишу на нём и разбираю код так, чтобы сложные вещи становились понятнее — подписывайтесь: t.me/rust_code

Спасибо за внимание, пишите в комментах, плиз ваши замечания!