Типы обертки в Rust: выбор гарантий

Перевод | Автор оригинала: Manish Goregaokar

Этот пост стал частью официальной книги Rust

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

Мне пришло в голову, что в Rust существует множество таких абстракций, каждая со своими уникальными гарантиями. У программиста снова есть выбор между принудительным исполнением и временем компиляции. Мне пришло в голову, что это множество «типов обертки» 1 может устрашить новичков; В этом посте я намерен дать подробное объяснение того, что делают некоторые известные из них и когда их следует использовать.

Я предполагаю, что читатель знает о владении и заимствовании в Rust. Тем не менее, я постараюсь, чтобы большая часть этого поста была доступна для тех, кто еще не знаком с этими концепциями. Помимо двух ссылок на книгу выше, эти два сообщения в блоге подробно освещают эту тему.

Основные типы указателей

Коробка

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

let x = Box::new(1);
let y = x;
// x no longer accessible here

Здесь коробка была перемещена в y. Поскольку x больше не владеет им, компилятор больше не позволит программисту использовать x после этого. Блок аналогичным образом может быть перемещен из функции путем возврата, и когда блок (тот, который не был перемещен) выходит за пределы области видимости, запускаются деструкторы, освобождая внутренние данные.

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

Интерлюдия: Копировать

Семантика перемещения / владения не является особенной для Box; это функция всех типов, не являющихся копией.

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

Такие типы, как Vec и String, которые также содержат данные в куче, также не являются копией. Типы, подобные целочисленным / логическим типам, - это Копировать

&T и необработанные указатели - это Копировать. Несмотря на то, что они указывают на дополнительные данные, они не «владеют» этими данными. В то время как Box можно рассматривать как «некоторые данные, которые случайно распределяются динамически», а &T рассматривается как «заимствующая ссылка на некоторые данные». Хотя оба являются указателями, только первый считается «данными». Следовательно, копия первого должна включать в себя копию данных (которая не является частью его представления стека), а для копии второго требуется только копия ссылки. &mut T не является копией, потому что изменяемые псевдонимы не могут быть общими, а &mut T в некоторой степени «владеет» данными, на которые он указывает, поскольку он может видоизменяться.

Фактически, тип может быть копируемым, если копия его представления в стеке не нарушает безопасность памяти.

&T и &mut T

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

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

* const T и * mut T

Это необработанные указатели в стиле C без привязки к времени жизни или праву собственности. Они просто указывают на какое-то место в памяти без каких-либо других ограничений. Единственная гарантия, которую они предоставляют, - то, что они не могут быть разыменованы, за исключением кода, помеченного как небезопасный.

Они полезны при создании безопасных и недорогих абстракций, таких как Vec, но их следует избегать в безопасном коде.

Rc

Это первая оболочка, которую мы рассмотрим, которая требует затрат времени выполнения.

Rc - указатель с подсчетом ссылок. Другими словами, это позволяет нам иметь несколько указателей-владельцев на одни и те же данные, и данные будут освобождены (деструкторы будут запущены), когда все указатели выйдут за пределы области видимости.

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

Внутренние данные здесь неизменяемы, и если создается цикл ссылок, данные будут утекать. Если нам нужны данные, которые не утекают во время циклов, нам нужен сборщик мусора. Я не знаю ни одного существующего сборщика мусора в Rust, но я работаю над одним с Никой Лейзелл, а есть еще один цикл сбора одного, написанный Ником Фицджеральдом.

Гарантии

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

Это следует использовать, когда вы хотите динамически распределять и совместно использовать некоторые данные (только для чтения) между различными частями вашей программы, когда неизвестно, какая часть завершит использование указателя последней. Это жизнеспособная альтернатива &T, когда &T либо невозможно статически проверить на правильность, либо создает крайне неэргономичный код, с которым программист не желает тратить затраты на разработку, работая с.

Этот указатель не является потокобезопасным, и Rust не позволяет отправлять его или передавать другим потокам. Это позволяет избежать затрат на атомики в ситуациях, когда они не нужны.

У этого есть родственный умный указатель, Weak. Это не владеющий, но и не заимствованный умный указатель. Он также похож на &T, но не ограничен временем жизни - Weak может храниться вечно. Однако возможно, что попытка доступа к внутренним данным может потерпеть неудачу и вернуть None, так как это может пережить принадлежащие Rcs. Это полезно, когда кому-то нужны циклические структуры данных и другие вещи.

Расходы

Что касается памяти, Rc - это единичное выделение, хотя оно выделяет два дополнительных слова по сравнению с обычным Box(для «сильного» и «слабого» счетчиков ссылок).

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

Типы ячеек

«Клетки» обеспечивают внутреннюю изменчивость. Другими словами, они содержат данные, которыми можно манипулировать, даже если тип не может быть получен в изменяемой форме (например, когда он стоит за & -ptr или Rc).

В документации на модуль ячейки есть довольно хорошее объяснение этого.

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

Ячейка

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

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

let x = Cell::new(1);
let y = &x;
let z = &x;
x.set(2);
y.set(3);
z.set(4);
println!("{}", x.get());

Обратите внимание, что здесь мы смогли изменить одно и то же значение из различных неизменяемых ссылок.

Это имеет те же затраты времени выполнения, что и следующее:

let mut x = 1;
let y = &mut x;
let z = &mut x;
x = 2;
*y = 3;
*z = 4;
println!("{}", x;

но у него есть дополнительное преимущество - фактическая успешная компиляция.

Гарантии

Это ослабляет ограничение «отсутствие псевдонимов с изменяемостью» там, где в этом нет необходимости. Однако это также ослабляет гарантии, предоставляемые ограничением; поэтому, если инварианты зависят от данных, хранящихся в Cell, следует быть осторожным.

Это полезно для изменения примитивов и других типов копирования, когда нет простого способа сделать это в соответствии со статическими правилами & и &mut.

Габор Лехель довольно кратко резюмировал гарантии, предоставленные Cell:

Основная гарантия, которую мы должны гарантировать, - это то, что внутренние ссылки не могут быть признаны недействительными (оставлены висящими) в результате мутации внешней структуры. (Подумайте о ссылках на внутренности таких типов, как Option, Box, Vec и т.д.) &, &Mut и Cell делают здесь разные компромиссы. & разрешает общие внутренние ссылки, но запрещает мутации; &mut разрешает мутацию xor внутренних ссылок, но не разрешает совместное использование; Ячейка допускает общую изменчивость, но не внутренние ссылки.

В конечном итоге, хотя общая изменчивость может вызвать множество логических ошибок (как описано в моем предыдущем посте ), это может вызвать ошибки безопасности памяти только в сочетании с «внутренними ссылками». Это для тех, у кого есть «интерьер», тип / размер которого можно изменять. Одним из примеров этого является перечисление Rust; где, изменяя вариант, вы можете изменить, какой тип содержится. Если у вас есть псевдоним внутреннего типа во время изменения варианта, указатели внутри этого псевдонима могут быть недействительными. Точно так же, если вы измените длину вектора, когда у вас есть псевдоним для одного из его элементов, этот псевдоним может стать недействительным.

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

Этот комментарий Эдди также касается гарантий Cell и альтернатив.

Расходы

Использование Cell не требует затрат времени выполнения, однако, если кто-то использует его для обертывания более крупных (копируемых) структур, может быть целесообразно вместо этого обернуть отдельные поля в Cell, поскольку каждая запись является полной копией структуры .

RefCell

RefCell также обеспечивает внутреннюю изменчивость, но не ограничивается типами копирования.

Вместо этого у него есть затраты времени выполнения. RefCell применяет шаблон RWLock во время выполнения (это похоже на однопоточный мьютекс), в отличие от &T / &mut T, которые делают это во время компиляции. Это осуществляется функциями заимствования() и заимствования_mut(), которые изменяют счетчик внутренних ссылок и возвращают интеллектуальные указатели, разыменование которых может быть неизменяемым и изменяемым соответственно. Refcount восстанавливается, когда интеллектуальные указатели выходят за пределы области видимости. С помощью этой системы мы можем динамически гарантировать, что никакие другие заимствования никогда не будут активными, когда активен изменяемый заем. Если программист попытается взять такой заем, поток запаникует.

let x = RefCell::new(vec![1,2,3,4]);
{
    println!("{:?}", *x.borrow())
}

{
    let my_ref = x.borrow_mut();
    my_ref.push(1);
}

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

Для больших и сложных программ становится полезным помещать некоторые вещи в RefCells, чтобы упростить работу. Например, многие карты в структуре ctxt во внутреннем устройстве компилятора Rust находятся внутри этой оболочки. Они изменяются только один раз (во время создания, а не сразу после инициализации) или пару раз в хорошо разделенных местах. Однако, поскольку эта структура повсеместно используется повсеместно, манипулирование изменяемыми и неизменяемыми указателями будет затруднено (возможно, невозможно) и, вероятно, образует суп из & -ptrs, который будет трудно расширить. С другой стороны, RefCell предоставляет дешевый (не нулевой) способ безопасного доступа к ним. В будущем, если кто-то добавит код, который пытается изменить ячейку, когда она уже заимствована, это вызовет (обычно детерминированную) панику, которая может быть связана с ошибочным заимствованием.

Точно так же в DOM Серво происходит множество мутаций, большая часть которых локальна для типа DOM, но некоторые из них пересекают DOM и изменяют различные вещи. Использование RefCell и Cell для защиты всех мутаций позволяет нам не беспокоиться об изменчивости повсюду, и одновременно выделяет места, где мутации действительно происходят.

Обратите внимание, что RefCell следует избегать, если с помощью указателей & возможно наиболее простое решение.

Гарантии

RefCell ослабляет статические ограничения, предотвращающие мутации с псевдонимом, и заменяет их динамическими. Таким образом, гарантии не изменились.

Расходы

RefCell не выделяет, но содержит дополнительный индикатор «состояния заимствования» (размером в одно слово) вместе с данными.

Во время выполнения каждое заимствование вызывает модификацию / проверку счетчика ссылок.

Синхронные типы

Многие из перечисленных выше типов нельзя использовать потокобезопасным образом. В частности, таким образом нельзя использовать Rc и RefCell, которые используют неатомарные счетчики ссылок. Это удешевляет их использование, но нужны и их поточно-ориентированные версии. Они существуют в виде Arc и Mutex / RWLock.

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

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

Дуга

Arc - это просто версия Rc, в которой используется атомарный счетчик ссылок (отсюда «Arc»). Его можно свободно пересылать между потоками.

Shared_ptr в C++ похож на Arc, однако в случае C++ внутренние данные всегда изменяемы. Для семантики, аналогичной семантике C++, мы должны использовать Arc<Mutex>, Arc<RwLock> или Arc<UnsafeCell> 3 (UnsafeCell - это тип ячейки, который можно использовать для хранят любые данные и не требуют затрат времени выполнения, но для доступа к ним требуются небезопасные блоки). Последний следует использовать только в том случае, если вы уверены, что его использование не вызовет небезопасности памяти. Помните, что запись в структуру не является атомарной операцией, и многие функции, такие как vec.push(), могут перераспределяться внутри и вызывать небезопасное поведение (поэтому даже монотонности4 может быть недостаточно, чтобы оправдать UnsafeCell)

Гарантии

Как и Rc, это обеспечивает (потокобезопасную) гарантию того, что деструктор для внутренних данных будет запущен, когда последняя дуга выйдет из области видимости (без каких-либо циклов).

Расходы

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

Mutex и RwLock

Mutex и RwLock обеспечивают взаимное исключение через защиту RAII. Для обоих из них мьютекс непрозрачен до тех пор, пока на нем не будет вызвана функция lock(), после чего поток будет блокироваться до тех пор, пока не будет получена блокировка, а затем будет возвращена защита. Эту защиту можно использовать для доступа к внутренним данным (изменчиво), и блокировка будет снята, когда защита выйдет за пределы области видимости.

{
    let guard = mutex.lock();
    // guard dereferences mutably to the inner type
    *guard += 1;
} // lock released when destructor runs

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

Гарантии

Оба они обеспечивают безопасную совместную изменчивость между потоками, однако они подвержены взаимоблокировкам. Некоторый уровень дополнительной безопасности протокола может быть получен с помощью системы типов. Примером этого является rust-sessions, экспериментальная библиотека, которая использует типы сессий для обеспечения безопасности протокола.

Расходы

Они используют внутренние атомарные типы для поддержки блокировок, и они аналогичны довольно дорого (они могут блокировать все операции чтения памяти через процессоры, пока они не будут выполнены). Ожидание этих блокировок также может быть медленным при большом количестве одновременного доступа.

Сочинение

Распространенная проблема при чтении кода на Rust - это такие вещи, как Rc <RefCell <Vec>> и более сложные композиции таких типов.

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

Например, Rc <RefCell> - одна из таких композиций. Само по себе Rc не может быть разыменовано взаимно; поскольку Rc обеспечивает совместное использование, а общая изменчивость - это плохо, поэтому мы поместили RefCell внутрь, чтобы получить динамически проверяемую общую изменчивость. Теперь у нас есть общие изменяемые данные, но они разделены таким образом, что может быть только один мутатор (и не считыватели) или несколько считывателей.

Теперь мы можем пойти дальше и иметь Rc <RefCell <Vec>> или Rc <Vec<RefCell>>. Оба эти вектора являются общими, изменяемыми, но это не одно и то же.

В первом случае RefCell оборачивает Vec, поэтому Vec полностью является изменяемым. В то же время может быть только одно изменяемое заимствование всего Vec в данный момент времени. Это означает, что ваш код не может одновременно работать с разными элементами вектора из разных дескрипторов Rc. Тем не менее, мы можем нажимать и выталкивать из Vec по желанию. Это похоже на &mut Vec с проверкой заимствования, выполняемой во время выполнения.

В последнем случае заимствованы отдельные элементы, но общий вектор неизменен. Таким образом, мы можем независимо заимствовать отдельные элементы, но не можем выталкивать или выталкивать из вектора. Это похоже на &mut [T] 5, но, опять же, проверка заимствования выполняется во время выполнения.

В параллельных программах мы имеем аналогичную ситуацию с Arc<Mutex>, который обеспечивает общую изменчивость и владение.

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

Выбирая составной тип, мы должны делать обратное; выяснить, какие гарантии мы хотим, и в какой точке композиции они нам нужны. Например, если есть выбор между Vec<RefCell> и RefCell <Vec>, мы должны выяснить компромиссы, как сделано выше, и выбрать один.

Обсудить: HN, Reddit

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

  2. Под «представлением стека» я подразумеваю данные в стеке, когда в стеке хранится значение этого типа. Например, Vec имеет стековое представление указателя и двух целых чисел (длина, емкость). Хотя за косвенным указателем стоит больше данных, они не являются частью хранимой в стеке части Vec. Если посмотреть на это с другой стороны, типом будет Копировать, если копия данных копирует все данные, которыми она владеет.

  3. Arc<UnsafeCell> на самом деле не будет компилироваться, поскольку UnsafeCell не является Send или Sync, но мы можем обернуть его в тип и реализовать Send / Sync для него вручную, чтобы получить Arc<Wrapper> где Wrapper - это struct Wrapper(UnsafeCell).

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

  5. &[T] и &mut [T] - срезы; они состоят из указателя и длины и могут относиться к части вектора или массива. Элементы &mut [T] могут изменяться, однако его длина не может быть изменена.