Ну, привет!

Мы знаем, что Rust славится непреступной безопасностью и контролем за доступом к данным, но за этими правилами стоит огромная философия и формальная модель.

Сегодня же мы с вами посмотрим изнанки модели памяти Rust. Поговорим о том, кто имеет право на доступ к памяти, что такое provenance указателей, почему &mut T считается эксклюзивным и как все это добралось до уровня оптимизатора LLVM через атрибут noalias.

Права на память

В Rust управление доступом к памяти строится вокруг концепций владения и заимствования. Уверен, вы знаете эти слова из любого введения в Rust, но тут важно понять, как они проецируются на низкий уровень работы с памятью. Владелец, например, переменная типа String или Vec<T> отвечает за выделение и освобождение памяти. Когда создается ссылка, мы как бы одалживаем память у владельца на время использования ссылки. Но Раст не был бы Растом, если бы не наложил дополнительные ограничения на эти займы.

Правило 1: когда у нас есть изменяемая ссылка &mut T, она должна быть единственной в своём роде на этот участок памяти. В общем, пока существует &mut на какой-то объект, никакие другие ссылки не могут указывать на те же данные. Это и называется уникальностью &mut. Компилятор Раста блюдёт это правило на уровне типов, в результате без unsafe-кода физически не выйдет создать вторую &mut на то же самое место. А если попытаетесь схитрить в unsafe-блоке, что ж, формально поведение программы станет неопределённым. Чуть позже мы убедимся, почему нарушение этого правила так неприятно.

Правило 2: Когда у нас есть разделяемая ссылка &T (она же неизменяемая), нельзя менять соответствующие данные через другие пути. На время жизни &T память по этому адресу считается только для чтения, по крайней мере, с точки зрения безопасного кода. Замечу важную вещь, именно сами данные нельзя менять, а вот получать к ним другие неизменяемые ссылки, пожалуйста, сколько угодно. В этом и прелесть разделяемых ссылок, их может быть много, но все они только читают.

Эти два правила вот такое вот сердце модели памяти Rust на уровне пользователя. Они известны как «один писатель или много читателей» (aliasing XOR mutability). Если активно пишем, то единственным указателем. Если указателей много, то никто не пишет.

Простая идея, проверяемая еще на этапе компиляции, творит чудеса. Огромное число багов с неопределённым поведением отсекается на корню.

Однако в системном языке одних этих правил недостаточно. Нужно ещё учесть, когда указатель теряет право на память, и что вообще значит «право доступа». Для этого понадобится копнуть глубже. В сторону понятий provenance и динамических правил для небезопасного кода. Но прежде чем перейти к полной формальности взглянем на небольшой пример, показывающий почему уникальность &mut так нужна.

Нарушаем aliasing-правила

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

use std::ptr;

fn main() {
    let mut data: i32 = 10;
    let ptr1: *mut i32 = &mut data as *mut i32;
    // Создаём вторую &mut, обойдя проверку компилятора:
    let ptr2: *mut i32 = ptr1;
    unsafe {
        // Разыменуем оба указателя
        *ptr1 = 20;
        *ptr2 = 30;
    }
    println!("data = {}", data);
}

Получили сырой указатель ptr1 на data, а затем скопировали его в ptr2. Теперь ptr1 и ptr2 два сырых указателя, указывающих на один и тот же data. В блоке unsafe через один записали значение 20, через другой 30. В результате, казалось бы, data должно стать равным 30, и программа выведет data = 30. И действительно, если вы запустите такой код без оптимизаций, скорее всего, так и будет.

Однако с точки зрения Rust мы совершили просто грубейшное нарушение. По правилу уникальности &mut у нас вообще не должно было существовать двух таких указателей одновременно. Мы обошли правило, а значит, зашли на территорию Undefined Behavior.

Что плохого может случиться? Да что угодно!

Например, компилятор, полагаясь на правило, может решить, что никакого второго указателя нет, и спокойно закешировать значение data в регистр, не читая его повторно. Представьте, что он переставил наши строчки местами или вообще решил, что запись *ptr2 = 30 не нужна, ведь ptr2 не может указывать на ту же память, что и ptr1. В худшем случае оптимизатор выбросит одну из двух операций как лишнюю или объединит их некорректно. Программа может вывести и 20, и 30, и вообще что-нибудь странное, и никаких гарантий тут нет.

А вообще, если запустить такой код под специальным интерпретатором Miri, он сразу же ругается на нарушение правил заимствования. Другими словами, Rust сдал нас с потрохами: мы попробовали соврать компилятору, будто &mut уникальна, а сами сунули ему два, и он нас за это наказывает. Возможно, конкретно в этом случае ничего катастрофического бы не произошло, но компилятор имеет право произвести любые преобразования кода, раз мы нарушили его базовые предпосылки. Поэтому запомните как мантру, если уж берете на себя ответственность писать unsafe-код, вы обязаны самостоятельно соблюдать правило уникальности для &mut, иначе получаете нерабочий код.

Provenance указателя

Хорошо, с правилом «никаких двойных &mut» всё ясно. Но есть еще более фундаментальный вопрос: что вообще значит «указатель указывает на память»?

Казалось бы, глупость: указатель — это просто число, адрес в памяти. А нет, в современной модели компилятора указатель хранит не только адрес, но и происхождение (provenance) — своеобразный «паспорт», подтверждающий право доступа к определенной области памяти. Звучит странновато.

Представьте, что мы выделили динамически кусок памяти, скажем, через Box::new(5). Получили указатель (в виде ссылки &i32) на эту память. Теперь мы освобождаем память (в Rust это происходит автоматически при выходе из области видимости Box). Казалось бы, у нас остался бывший адрес, число, которое раньше указывало на эту область. В какой‑то другой части программы может снова выделиться память по точно такому же адресу (совпадение, но бывает).

Если указатель был бы просто числом, ничего не подозревающий человек мог бы решить: «О, адрес тот же самый, можно воспользоваться старым указателем!». Но модель памяти запрещает так делать – и именно благодаря концепции pointer provenance.

Происхождение указателя включает в себя время жизни и границы памяти, на которые он имеет право. После освобождения исходной области все указатели, ссылающиеся на неё, теряют свои права (их temporal provenance истек). Даже если по тому же адресу разместится новый объект, старый указатель не обретет доступ к нему. С точки зрения компилятора, это совершенно другой «памятный паспорт».

Другой случай. Пространственные границы. Указатель знает размер того блока памяти, к которому он привязан. Если у вас есть указатель на элемент массива, вы не можете сдвинуть этот указатель за пределы исходного массива и потом разыменовать, даже если там, сразу за массивом, лежит какая‑то допустимая память. Нарушение границ — снова мимо правил, снова Undefined Behavior. Здесь опять таки важен provenance,указатель хранит метаданные о том, на какую выделенную область он ссылается. Всю память программы можно мысленно разбить на отдельные аллокации, глобальные переменные, куча, стек каждого потока. Так вот, у каждого указателя есть идентификатор аллокации, от которой он произошёл. Вы не можете законно получить указатель на одну аллокацию, а потом арифметикой заставить его указывать на другую, это будет указатель без прав на новую область. А без прав, извольте, нельзя разыменовать.

Таким образом, формально право доступа к памяти в Rust определяется тройкой факторов: происхождение (аллокация), границы (смещение/размер) и время жизни (не освобождена ли память). Ещё добавим туда признак мутабельност, указатель может быть помечен как только для чтения или для записи. Например, неизменяемая &T имеет provenance, разрешающий чтение данной области, но не запись (если только внутри UnsafeCell, о котором чуть позже).

Итак, Rust на уровне модельных правил рассматривает указатель не как адрес в вакууме, а как адрес +контекст происхождения. Этот контекст определяет, можно ли сейчас разыменовать указатель, сколько байт памяти он охватывает, и какие операции допустимы. Если вы попытаетесь обращаться к памяти, не входящей в provenance вашего указателя, получите Undefined Behavior. Такая строгость не только предотвращает ошибки, но и даёт компилятору уверенность для оптимизаций.

Уникальность &mut: зачем так строго?

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

Вспомним наш ранний пример, где мы гипотетически создали две aliasing ссылки и получили потенциально некорректную оптимизацию. Там проблема в том, что компилятор не был уверен, alias ли эти указатели или нет. Rust решает эту проблему радикально, он изначально запрещает сцену, где два &mut указывают на одну память. Следовательно, в любом сгенерированном машинном коде можно смело считать, что два разных &mut T не пересекаются по памяти. Эта гарантия передается на уровень LLVM в виде специальной аннотации, о ней чуть позже.

Можно взглянуть и с другой стороны.

&mut T по сути эквивалентен контракту restrict из C. Если вы знакомы с C99, там ключевое слово restrict для указателей обещает, что через этот указатель и только через него будет изменяться соответствующий объект. В Rust этот контракт встроен в сам тип &mut, вам не нужно писать его явно, он подразумевается по дефолту. Конечно, оговаривается, что это правило действует только в рамках безопасного кода, в unsafe вы можете получить сырой указатель *mut T и поделиться им, но ответственность за дальнейшее лежит на вас. Если вы в unsafe породили две изменяемых ссылки, как мы сделали выше, вы нарушили контракт, получаете UB, а компилятор имеет право работать будто ничего не случилось и считать &mut уникальными (что он и делает).

Кстати, важное уточнение, сырые указатели (*mut T и const T) в Rust не обладают такими строгими aliasing-гарантиями. Они вообще почти ничем не обладают на уровне типов.

Поэтому, если у функции параметры типа mut T, компилятор не будет по дефолту считать их неалиасующими. Можно передать в функцию два одинаковых сырых указателя, и оптимизатор должен исходить из худшего сценария. В итоге для сырого указателя Rust не будет проставлять никаких noalias метаданных, ему не на что опереться, кроме ваших гарантий извне. А вот для &mut вполне будет, потому что язык сам гарантирует их эксклюзивность.

Исключение: UnsafeCell и внутренняя мутабельность

А как же внутреняя мутабельность? Ведь в Rust есть типы вроде Cell<T> или всем известный RefCell<T>, которые позволяют менять данные через неизменяемые ссылки.

Получается, правило «&T ничего не меняет» нарушается? На первый взгляд да, но на самом деле нет, просто такие типы используют небольшой лазейку в правилах, имя которой std::cell::UnsafeCell<T>.

UnsafeCell<T> является единственным легальным способом сказать компилятору, что вот эту штуку можно менять, даже если она заалиасена иммутабельными ссылками. Короче говоря, UnsafeCell снимает запрет на мутацию через shared reference. Если какой-то кусок данных заключён в UnsafeCell, то даже имея на него неизменяемую ссылку &T, внутренности T можно менять. Именно поэтому стандартная библиотека оборачивает всякие Cell/RefCell внутри себя в UnsafeCell,чтобы обойти правило неизменяемости.

НоUnsafeCell не отключает правило уникальности для &mut! Всего лишь позволяет существовать ситуации, где одна область памяти алиасится несколькими &T, но при этом модифицируется легально. Типичный пример — глобальная переменная, защищённая мьютексом, у вас может быть много потоков с неизменяемыми ссылками на &GlobalData, но внутри GlobalData сидит UnsafeCell, через который идёт изменение. С точки зрения компилятора, aliasing есть, но раз все изменения происходят внутри UnsafeCell, он считает это допустимым нарушением. Чтобы не запутаться, помните простое правило,&T по-прежнему не дает права менять данные, если только эти данные не хранятся внутри UnsafeCell. А &mut T по прежнему эксклюзивна, даже к UnsafeCell применимо, что одновременно два &mut на один UnsafeCell быть не должно.

Приведу небольшой пример использования внутренней мутабельности для наглядности:

use std::cell::Cell;

fn main() {
    let data = Cell::new(100);
    let ref1: &Cell<i32> = &data;
    let ref2: &Cell<i32> = &data;
    // У нас две неизменяемые ссылки на data, и это ок,
    // потому что мы не нарушаем правило - не мутируем через & без UnsafeCell.
    // Но Cell внутри содержит UnsafeCell, так что поменять значение можно:
    ref1.set(200);
    println!("Through ref2: {}", ref2.get());
}

ref1 и ref2 обычные ссылки на Cell<i32>. Мы вызвали метод set на ref1, и он успешно изменил внутреннее значение на 200, что подтверждается выводом через ref2.get(). С точки зрения безопасного кода, всё чисто, у нас же &Cell<i32>, а не &i32. Правило о &T говорит, что нельзя менять то, на что указывает &T, но Cell аккуратно инкапсулирует внутри себя мутабельное состояние с помощью UnsafeCell.

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

noalias в LLVM

Rust компилируется в низкоуровневый промежуточный код LLVM IR, и далее уже LLVM генерирует машинный код. Чтобы LLVM мог применить какие-то агрессивные оптимизации, Rust должен донести до него свою семантику. Одной из основных подсказок является как раз информация о неалиасующих указателях. В LLVM IR для этого служит атрибут функции/параметра под названием noalias, по сути то же самое, что restrict в C. Если параметр функции помечен как noalias, это означает, что любые две разные ссылки, помеченные noalias, не указывают на пересекающиеся области памяти. То есть их можно считать независимыми.

Rust, разумеется, очень хочет пометить свои &mut как noalias, ведь по замыслу они уникальные. И действительно, начиная с ранних версий компилятора, rustc старался расставлять noalias в LLVM IR везде, где речь шла про &mut параметры. Однако тут была засада, оказалось, что Rust напряжённость aliasing-правил выявила в LLVM несколько багов. Случались ситуации, когда LLVM получал функции с десятками noalias-указателей и оптимизатор начинал делать недопустимые преобразования, приводившие к неправильному коду. Rust настолько усердно пометил все указатели как noalias, что загнал оптимизатор в угол малоисследованных сценариев. Пришлось притормозить.

История такая, в версии Rust 1.0 noalias для &mut был включен по дефолту, но уже к Rust 1.8 отключен из-за найденных неверных оптимизаций. Потом баги в LLVM поправили, снова включили, потом опять нашли проблемы, снова отключили. В общем, качели длились несколько лет.

Посмотрим простой пример, иллюстрирующий смысл этой информации. Возьмём функцию, которая складывает два числа по ссылкам и записывает результат по одной из них дважды:

#[inline(never)]
fn adds(a: &mut i32, b: &mut i32) {
    *a += *b;
    *a += *b;
}

С точки зрения логики, adds(a,b) просто два раза добавляет значение b к a. Если бы a и b могли указывать на одно место, то второй раз мы бы добавляли уже обновлённое значение. Но Rust гарантирует, что &mut a и &mut b не алиас, поэтому фактически можно считать, что мы дважды добавляем одно и то же исходное значение *b.

Компилятор с такой подсказкой может упростить эту функцию. Сделать всего один загрузку из b и удвоить её, а потом один раз прибавить к a. На ассемблере это будет что-то вроде:

mov eax, [rsi]    ; загружаем *b в регистр EAX
add eax, eax      ; складываем EAX сам с собой (т.е. EAX = *b + *b)
add [rdi], eax    ; прибавляем получившееся к *a
ret

Три инструкции вместо пяти. Оптимизатор сумел не читать b дважды, потому что знает, память по rsi (второй аргумент) не меняется в промежутках. Без noalias такой фокус невозможен, компилятор вынужден предположить, что вдруг rdi и rsi указывают на одно место, и тогда после первого a += b значение b могло измениться. В этом случае он обязан выполнить вторую загрузку (*b) заново. Действительно, если скомпилировать эту функцию без оптимизации aliasing, получится последовательность из двух чтений b и двух записей a.

Можно спросить, а что, без noalias компилятор совсем не умеет оптимизировать? Умеет, конечно, но гораздо осторожнее. Например, он всё равно не сделает глупостей типа записи неинициализированной памяти или неверного переупорядочения видимых эффектов. Но вот нюансы, завязанные на анализ aliasing, без подсказок остаются неразрешимыми.

Состояние дел

Раз уж мы заговорили про LLVM, отмечу текущую ситуацию: начиная с Rust 1.54 атрибут noalias вновь стал включаться по умолчанию на платформах с LLVM 12+.

Сейчас Rust компилируется обычно на достаточно новых версиях LLVM, так что в релизных сборках ваше &mut скорее всего действительно будет помечено как noalias.

Тем не менее, формально спецификация Rust пока не железобетонна в этом моменте, то есть где-то в справочнике вы не найдёте гарантии “&mut всегда noalias”. Это считается деталей реализации, которую могут корректировать ради безопасности. Например, есть известный баг. Если из &mut сделать сырой указатель, а потом снова создать &mut (через тот же сырой), компилятор ранее мог потерять noalias, потому что не был уверен, не алиасится ли новый указатель с кем-то ещё. В общем, доверяйте компилятору, он уже достаточно умён и адекватен, чтобы оптимизировать независимые ссылки, но помните, что в unsafe коде вся ответственность на вас.

Немного про будущее

Rust, хотя и не запечатлел ещё окончательно свою модель памяти в спецификации, фактически уже её придерживается в компиляторе и инструментах. Работает группа Unsafe Code Guidelines, документы которой описывают все эти правила, про &mut, про pointer provenance, про UnsafeCell и др.

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

В общем это все понятно, но что нам из этого важно вынести? Простые правила:

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

  • Доверяйте компилятору. Он действительно знает про уникальность &mut и использует эту инфу. Не пытайтесь переиграть его, это бесполезно и опасно.

Rust выбрал трудный путь. Формализовать, запретить неопределённость и давать хорошие инструменты.

Спасибо, что дочитали до конца.


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

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

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