Пытаемся разобраться в стековом заимствовании

В предыдущем разделе мы попытались запустить нашу небезопасную односвязную очередь в miri. Он сказал, что мы нарушили правила стековых заимствований и дал нам ссылку на документацию.

Обычно я разбираю доку, но не в этот раз. На самом деле мы не являемся целевой аудиторией этой документации. Она написана для разработчиков компилятора и академиков, которые работают над семантикой Rust.

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

ГОЛОС ЗА КАДРОМ: стековые заимствования остаются «экспериментальными» в качестве семантической модели Rust. Нарушение этих правил не означает, что у вас «неправильная» программа. Но если вы — не разработчик компилятора, лучше просто исправьте программу, если miri на неё ругается. Это намного безопаснее, чем жалеть, когда возникнет Неопределённое Поведение.

Причина: псевдономизация указателей

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

Мы называем два указателя псевдонимами, если они указывают на перекрывающиеся области памяти. Как кого-то «известного под псевдонимом» можно называть двумя различными именами, так и область памяти может быть доступна через два различных указателя. И это может приводить к проблемам.

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

ГОЛОС ЗА КАДРОМ: на практике, псевдономизация больше связана с доступом к памяти, чем с самими указателями, и имеет значение только тогда, когда один из указателей является изменяющим. Внимание к указателям связано с тем, что к ним удобно прикреплять правила.

Чтобы понять, почему информация о псевдономизации указателей так важна, послушайте Притчу о сердитом человечке.


Однажды Михил осматривал свои книжные полки и увидел незнакомую книгу. Сняв её с полки, он посмотрел на обложку.

«Ах, да, мой старый экземпляр Войны и мира, книга, которую я наверняка прочитал. Люблю часть, посвящённую миру.»

Внезапно в дверь постучали. Михил вернул книгу на полку и открыл дверь — там стояла его непримиримая противница Хамслава.

«Привет, Хамслава, ты когда-нибудь читала Войну и мир?»

«Пффф, на самом деле никто не читал Войну и мир.»

«Ну, а я читал, смотри, она у меня прямо на полке, что очевидно означает, что я её прочёл.»

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

Михил уже был готов насладиться своим очевидным превосходством, но его прервал внезапный смех Хамславы.

«Это не Война и мир, это Война и тир!

Слёзы текли по лицу Хамславы. Несомненно, это был лучший момент в её жизни.

«Н-нет! Я же только что смотрел!»

Он вырвал книгу из рук Хамславы и посмотрел на обложку. Действительно, слово «мир» было зачёркнуто и исправлено на «тир». Михил в ужасе застыл. Несомненно, это был худший момент в его жизни.

Он упал на колени и беспомощно уставился на книжный шкаф. Как это могло произойти? Он же видел обложку мгновенье назад!

Тут он заметил какое-то движение в шкафу. Это был человечек. Человечек с самым сердитым выражением лица, которое Михил когда-либо видел. Он показал Михилу средний палец и, сказав «тебе никто не поверит», скрылся между книгами.

План Михила был идеальным, но не учитывал появления сердитого человечка с маркером в руках и жаждой разрушения в душе. Он думал, что знает, что написано на обложке и считал, что никто не может этого изменить. Но, увы, он ошибался.

А Хамслава в красках описала свою невероятную победу в стенгазете, так что репутация Михила в местном Интернет-кафе была разрушена навсегда.


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

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

ГОЛОС ЗА КАДРОМ: компилятор также использует эту информацию для кеширования хранений, то есть он избегает отправки данные в память, если думает, это этого никто не заметит. Причина проблемы всё ещё в сердитых человечках, но им надо прочитать память, чтобы проблема проявилась.

Безопасные стековые заимствования

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

На самом деле всё гораздо сложнее. Мы можем «повторно заимствовать» изменяемые указатели. Например:

#![allow(unused)]
fn main() {
let mut data = 10;
let ref1 = &mut data;
let ref2 = &mut *ref1;

*ref2 += 2;
*ref1 += 1;

println!("{}", data);
}

Компилируется и запускается без проблем. Почему?

Мы поймём, что здесь происходит, если поменяем две строки местами:

let mut data = 10;
let ref1 = &mut data;
let ref2 = &mut *ref1;

// ПОРЯДОК ИЗМЕНИЛСЯ!
*ref1 += 1;
*ref2 += 2;

println!("{}", data);
error[E0503]: cannot use `*ref1` because it was mutably borrowed
 --> src/main.rs:6:5
  |
4 |     let ref2 = &mut *ref1;
  |                ---------- borrow of `*ref1` occurs here
5 |     
6 |     *ref1 += 1;
  |     ^^^^^^^^^^ use of borrowed `*ref1`
7 |     *ref2 += 2;
  |     ---------- borrow later used here

For more information about this error, try `rustc --explain E0503`.
error: could not compile `playground` due to previous error

Внезапно теперь мы получаем ошибку компилятора!

Когда мы повторно заимствуем изменяемый указатель, оригинальный указатель нельзя использовать, пока заимствование не закончится.

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

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

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

Ага, вот и стек заимствований!

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

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

К счастью, конструкция анализатора заимствований гарантирует, что безопасные Rust-программы следуют этим правилам, что мы и видели в первом примере. Но компилятор, если сравнивать со стековыми заимствованиями, видит проблему «задом наперёд». С точки зрения стековых заимствований ref1 ломает ref2. Компилятор настаивает, что ref2 должен быть корректным в течение всего времени использования, и что ref1 нарушает порядок, действуя вне очереди.

Поэтому и «нельзя использовать *ref1, поскольку это изменяемое заимствование». Тот же самый результат, но, возможно, оформленный в более интуитивном виде (особенно,когда речь идёт о не-лексическом времени жизни).

Но анализатор заимствований не может помочь нам, если мы используем небезопасные указатели!

Небезопасные стековые заимствования

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

Это трудная проблема, и я не знаю, как её решить, но ребята, работавшие над стековыми заимствованиями, придумали что-то, что внушает доверие, и miri пытается воплотить эти идеи.

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

Весь вопрос в том, когда истекает срок повторного заимствования? Ну, кажется, что лучшее время для завершения заимствования, это когда вы начинаете использовать оригинальную ссылку. В противном случае всё это не выглядит как аккуратный вложенный стек.

Но подождите, вы можете превратить сырой указатель в ссылку! И вы можете копировать сырые указатели! Что будет, если вы сделаете &mut -> *mut -> &mut -> *mut, а затем обратитесь к первому *mut? Как, блин, стековые заимствования работают в этом случае?

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

Именно эта неразбериха является причиной появления экстра-экспериментального экстра-строгого режима miri: -Zmiri-tag-raw-pointers.

Чтобы включить этот режим, надо передать флаг через переменную окружения MIRIFLAGS:

MIRIFLAGS="-Zmiri-tag-raw-pointers" cargo +nightly-2022-01-21 miri test

Или можно установить переменную глобально, как в Windows:

$env:MIRIFLAGS="-Zmiri-tag-raw-pointers"
cargo +nightly-2022-01-21 miri test

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

Управление стековыми заимствованиями

При использовании сырых указателей мы будем придерживаться простой и понятной эвристики, которая, как я надеюсь, имеет большую толерантность к ошибкам:

Как только вы стали использовать сырые указатели, старайтесь использовать ТОЛЬКО сырые указатели.

Это сильно снижает возможность непредумышленной потери «права» сырого указателя на доступ к памяти.

ГОЛОС ЗА КАДРОМ: у этого упрощения есть два аспекта:

  1. У безопасных указателей часто есть другие свойства, помимо псевдономизации: память выделена, выровнена, её достаточно для хранения объекта указывания, объект указывания инициализирован и т. д. Поэтому так опасно разбрасывать их везде, когда они находятся в нестабильном состоянии.
  2. Даже если вы используете только сырые указатели, вы не можете использовать псевдонимы для доступа к любой памяти. Указатели концептуально привязаны к определённым «областям выделенной памяти» (которые могут быть такими же мелкими, как и локальная переменная на стеке). Нельзя просто взять указатель на одну область, прибавить к нему смещение и получить указатель на другую область. Если бы это было возможно, угроза сердитых человечков была бы всегда и везде. Именно по этой причине точка зрения «указатели — всего лишь целые числа» является проблематичной.

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

Вот что мы будем делать:

  1. В начале метода используем входные ссылки, чтобы получить сырые указатели
  2. Далее будем использовать только сырые указатели
  3. В конце, если нужно, преобразуем сырые указатели в безопасные указатели

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

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

Но сырой указатель, в котором мы хранили конец списка, указывает на Box, поэтому всякий раз, когда мы обращаемся к Box, мы, возможно, ломаем повторное заимствование этого сырого указателя!

В следующем разделе мы вернёмся в нашему привычному формату и разберёмся с целым ворохом непростых примеров.