Небезопасные абстракции
Ключевое слово unsafe
является неотъемлемой частью дизайна языка Rust.
Для тех кто не знаком с ним: unsafe
— это ключевое слово, которое,
говоря простым языком, является способом обойти проверку типов (type checking) Rust’а.
Существование ключевого слова unsafe
для многих поначалу является неожиданностью.
В самом деле, разве то, что программы не «падают» от ошибок при работе с памятью,
не является особенностью Rust? Если это так, то почему имеется лёгкий способ
обойти систему типов? Это может показаться дефектом дизайна языка.
Все же, по моему мнению, unsafe
не является недостатком. На самом деле
он является важной частью языка. unsafe
выполняет роль некоторого выходного
клапана — это значит то, что мы можем использовать систему типов в простых случаях,
однако позволяя использовать всевозможные хитрые приёмы, которые вы хотите
использовать в вашем коде.
Мы только требуем, чтобы вы скрывали эти ваши приёмы (unsafe
код) за безопасными внешними абстракциями.
Данная заметка представляет ключевое слово unsafe
и идею ограниченной «небезопасности».
Фактически это предвестник заметки, которую я надеюсь написать чуть позже.
Она обсуждает модель памяти Rust, которая указывает, что
можно, а что нельзя делать в unsafe
коде.
«Небезопасный» код как плагин
Я думаю, что-то, как интерпретируемые языки, подобные Ruby (или Python) используют
код на C, является хорошим сопоставлением с работой unsafe
в Rust.
Возьмём, скажем, JSON модуль в Ruby. Он включает в себя как реализацию
на Ruby (JSON: Pure), так и альтернативную реализацию на C (JSON: Ext).
Обычно когда вы используете модуль JSON, вы запускаете С код, но Ruby код
не взаимодействует с ним так же как и с обычным Ruby кодом. Внешне данный код выглядит так
же как и любой другой модуль на Ruby, но внутри он может использовать
разные хитрые приёмы и выполнять оптимизации, которые невозможно написать
только в коде на самом Ruby. (Можете почитать эту превосходную статью на Helix,
чтобы узнать больше, также там можно узнать о том, как писать плагины к Ruby на Rust.)
Хорошо, такое же может случиться и в Rust, но в несколько другом масштабе.
Например, можно написать производительную реализацию хэш-таблицы на «чистом» Rust.
Добавление же unsafe
кода позволит сделать этот код ещё быстрее.
Если данная структура данных будет использоваться многими людьми или её
работа является очень важной для вашей программы,
то это может стоить того (Поэтому мы используем unsafe
код
в реализации стандартной библиотеки)
Однако в любом случае, вызывающий код на Rust обращается к unsafe
коду так же,
как и к не-unsafe
: наложенные уровни абстракции предоставляют единообразный
внешний API.
Разумеется, то, что использование unsafe
кода позволяет
сделать программу быстрее, не означает, что вы должны использовать его очень часто.
Так же как большинство Ruby кода написано на Ruby, большинство Rust кода
написано на safe Rust. Это верно ещё и потому, что safe Rust код очень эффективен,
так что выгоды от перехода к использованию unsafe
кода для достижения высокой производительности,
редко стоят приложенных на это усилий.
Думается, что самым частым случаем использования unsafe
кода на Rust является
использование библиотек на других языках через FFI (Foreign Function Interface).
Каждый вызов C функции из Rust
является unsafe
, потому что компилятор никак не может судить о «безопасности»
С кода.
Расширение языка посредством unsafe
кода.
Я думаю, что интереснее всего писать unsafe
код на Rust (или C модуль на Ruby) для того,
чтобы расширить возможности языка.
Наверное, самым часто приводимым примером является тип Vec
в стандартной
библиотеке, которая использует unsafe
код для проведения манипуляций
с неинициализированной памятью. Rc
и Arc
, являющиеся счётчиками ссылок,
также являются показательным примером. Однако имеются гораздо более
интересные примеры, как-то: CrossBeam и deque используют unsafe
код для
реализации неблокирующих (lock-free) структур данных или Jobsteal и Rayon
используют unsafe
код для реализации пула потоков (thread pool).
В данной заметке мы рассмотрим один простой пример: метод split_at_mut
,
который имеется в стандартной библиотеке. Данный метод работает
с изменяемыми срезами (mutable slices). Также он принимает индекс (mid
)
и разделяет срез на две части по указанному индексу. Впоследствии он
возвращает два меньших среза: один с диапазоном 0..mid
, второй — в mid..
Для удобства можно представить себе split_at_mut
реализованным так:
1 2 3 4 5 | impl [T] { pub fn split_at_mut(&mut self, mid: usize) -> (&mut [T], &mut [T]) { (&mut self[0..mid], &mut self[mid..]) } } |
Данный код не будет скомпилирован по двум причинам:
- В общем случае компилятор не рассматривает индекс слишком «пристально»,
в отрыве от включающего его массива. Это значит, что когда он видит индексирование
вида
foo[i]
, он игнорирует индекс и обращается с массивом, как с единым целым (foo[_]
). Это значит, что он не может выявить то, что&mut self[0..mid]
является обращением к другому участку памяти, нежели&mut self[mid..]
. Это из-за того, что проведение подобно анализа потребовало бы гораздо более сложной системы типов. - Фактически оператор
[]
не является частью языка — он полностью реализован в стандартной библиотеке. Поэтому, даже если бы компилятор знал, что0..mid
иmid..
не перекрываются, из этого бы не следовало бы его знание о том, что данные диапазоны обращаться к неперекрывающимся областям памяти.
Можно себе вообразить, что возможно, изменяя компилятор, добиться того, что
указанный пример кода будет компилироваться, и, возможно, мы это однажды реализуем.
Но в настоящий момент мы предпочитаем реализовывать методы подобные split_at_mut
посредством unsafe
кода. Это позволяет нам иметь простую систему типов, имея
в возможность писать API подобный split_at_mut
.
Границы абстракции
Взгляд на unsafe
код как на подключаемый код позволяет ясно выразить идею
о «границах абстракции». Когда вы пишете плагин на Rust, вы ожидаете, что когда
вызывающий код на Ruby будет вызывать ваши функции, он будет предоставлять вам
«родные» для Ruby переменные.
Внутри же вы можете поступать как хотите, например, использовать C массив вместо
vector
а на Ruby. Но при переходе обратно к выполнению Ruby кода вы должны
преобразовать ваши возвращаемые сущности в стандартные для Ruby переменные.
Также обстоит дело и с unsafe
кодом на Rust. Клиентскому коду кажется,
что ваш код является safe. Это значит, что можно полагать, что вызывающий код
будет передавать на вход допустимые значения.
Это также значит, что все ваши значения, которые вы возвращаете, должны соответствовать
требованиям системы типов Rust. Находясь же внутри unsafe
границ, вы можете обходить
правила по своему усмотрению (разумеется, объем предоставляемых дополнительных
возможностей является темой для обсуждения; я надеюсь обсудить это в последующей
заметке).
Давайте посмотрим на метод split_at_mut
, который мы видели в прошлом разделе.
Для упрощения понимания, мы будем рассматривать только внешний интерфейс функции,
представляемый сигнатурой:
1 2 3 4 5 6 7 | impl [T] { pub fn split_at_mut(&mut self, mid: usize) -> (&mut [T], &mut [T]) { // Тело функции пропущено, так что мы можем сосредоточить внимание // на публичном интерфейсе. В любом случае безопасный код не должен // интересоваться тем, что здесь находится. } } |
Что мы может понять из этой сигнатуры?
Начнём с того, что split_at_mut
полагается на то, что все её входные данные
являются допустимыми (В safe-коде, компилятор проверяет, что это действительно так).
unsafe
семантика метода split_at_mut
может быть выражена в следующих правилах:
self
аргумент имеет типmut [T]
. Из этого следует, что мы получим ссылку, указывающую на некоторое (N) количество элементов типа T. Это изменяемая (mutable) ссылка, поэтому мы знаем, что к памяти, к которой обращаетсяself
, не может обращаться больше никто (пока изменяемая ссылка не перестанет существовать). Мы также знаем, что память инициализирована.mid
аргумент имеет типusize
. Все, что мы знаем, так это то, что данная переменная представляет собой неотрицательное целое число.
Есть ещё один неупомянутый момент. Нигде не гарантируется, что mid
индекс
является допустимым индексом для обращения к self
. Из этого вытекает
необходимость того, что unsafe
код, который мы будем писать, должен будет
проверять это.
Когда split_at_mut
завершается, он должен сделать так, чтобы возвращаемое значение
соответствовало сигнатуре. Проще говоря, это означает, что функцию должная возвращать
два допустимых (указывающих на выделенную память) подмассива (slice),
Также важно то, чтобы данные подмассивы не пересекались, то есть
представляли собой два неперекрывающихся участка памяти.
Возможные реализации
Давай посмотрим на несколько возможных реализаций split_at_mut
и определим,
являются ли они рабочими вариантами или нет. Мы уже видели, что реализация, написанная
на «чистом» Rust не работает (не компилируется).
Попробуем реализовать функцию, используя сырые (raw) указатели:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 | impl [T] { pub fn split_at_mut(&mut self, mid: usize) -> (&mut [T], &mut [T]) { use std::slice::from_raw_parts_mut; // `unsafe` блок даёт доступ к операциям с *сырым* указателем. // Используя `unsafe` блок, мы заявляем, что никакие наши действия // не будут причиной UB(undefined behaviour). unsafe { // получить *сырой* указатель на первый элемент let p: *mut T = &mut self[0]; // получить указатель на `mid` элемент let q: *mut T = p.offset(mid as isize); // количество элементов после `mid` let remainder = self.len() - mid; // "собрать" подмассив из элементов в диапазоне `0..mid` let left: &mut [T] = from_raw_parts_mut(p, mid); // "собрать" подмассив из элементов в диапазоне `mid..` let right: &mut [T] = from_raw_parts_mut(q, remainder); (left, right) } } } |
Эта версия наиболее приближена к той, которая реализована в стандартной библиотеке.
Однако данный код основывается на предположении, которое не обосновано входными значениями:
код предполагает, что mid
находится в границах массива.
Нигде не проверяется, что mid <= len
. Это значит, что q
может быть
вне границ массива, также это значит, что вычисление remainder
может вызвать
переполнение типа и обёртывание (wrap around),
Это некорректная реализация, потому что требует больше гарантий, чем требуется
от вызывающего кода.
Мы может исправить данную реализацию добавлением assert‘а того, что mid
является
допустимым индексом (заметьте, что assert в Rust всегда выполняется,
даже в оптимизированном коде):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 | impl [T] { pub fn split_at_mut(&mut self, mid: usize) -> (&mut [T], &mut [T]) { use std::slice::from_raw_parts_mut; // проверка, что `mid` находится в границах массива: assert!(mid <= self.len()); // как и раньше, но без комментариев unsafe { let p: *mut T = &mut self[0]; let q: *mut T = p.offset(mid as isize); let remainder = self.len() - mid; let left: &mut [T] = from_raw_parts_mut(p, mid); let right: &mut [T] = from_raw_parts_mut(q, remainder); (left, right) } } } |
Хорошо, здесь мы практически повторили реализацию данной функции в стандартной библиотеке (мы здесь использовали несколько другие вспомогательные инструменты, но, по сути, идея так же).
Расширяем границы абстракции
Конечно, могло так случиться, что мы на самом деле хотели считать,
будто mid
находится в допустимых границах, и хотели обойтись без этой проверки.
Мы не можем сделать этого, потому что split_at_mut
является частью стандартной
библиотеки. Однако вы можете представить себе вспомогательный метод для
вызывающего кода, который бы удостоверял это предположение, так что мы бы
обходились без дорогостоящей проверки на нахождение индекса в границах массива
во время выполнения. В этом случае, split_at_mut
полагается на вызывающий
вспомогательный код для того, чтобы можно было гарантировать нахождение
mid
в границах массива. Это значит, что split_at_mut
больше не является safe-кодом,
потому что имеет дополнительные требования к входным значениям, чтобы
гарантировать безопасную работу с памятью.
Rust позволяет выражать то, что весь код функции является unsafe
посредством
помещения ключевого слова unsafe
в сигнатуре функции.
После такого перемещения, «небезопасность» кода больше не является внутренней деталью
реализации функции, теперь это часть интерфейса функции. Так что мы можем
сделать вариант split_at_mut
— split_at_mut_unchecked
—
который не проверяет нахождение mid
в допустимых границах:
```rust
impl [T] {
// Здесь данная функция объявлена как unsafe
. Вызов данной
// функции является unsafe
действием для вызывающего кода,
// потому что они должны гарантировать инвариант: mid <= self.len()
.
unsafe pub fn split_at_mut_unchecked(& mut self, mid: usize) -> (& mut [T], & mut [T]) {
use std: slice: from_raw_parts_mut;
let p: *mut T = & mut self[0];
let q: *mut T = p.offset(mid as isize);
let remainder = self.len() — mid;
let left: & mut [T] = from_raw_parts_mut(p, mid);
let right: & mut [T] = from_raw_parts_mut(q, remainder);
(left, right)
}
}
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 | Когда `fn` объявлена как `unsafe` подобно тому, как это сделано выше, вызов её тоже становится `unsafe`. Это значит, что человек, который пишет вызывающий код, должен ознакомиться с документацией функции и убедиться, чтобы все условия соблюдены. А в данном конкретном случае вызывающий код должен убедиться, что `mid <= self.len()`. Если вы думаете о границах абстракции, то объявление `unsafe` означает, что это не является частью "безопасной" области Rust, где компилятор сам выявляет ошибки, проводя статический анализ на этапе компиляции. Напротив, это значит, что появляется новая абстракция, которая становится частью `unsafe` абстракции вызывающего кода. Используя `split_at_mut_unchecked`, мы можем изменить реализацию `split_at_mut` так, чтобы она внутри себя, проводя необходимые проверки, вызывала `split_at_mut_unchecked`: ```rust impl [T] { pub fn split_at_mut(&mut self, mid: usize) -> (&mut [T], &mut [T]) { assert!(mid <= self.len()); // Помещая `unsafe`-блок в функции, мы заявляем, что мы знаем // что дополнительные условия, наложенные на `split_at_mut_unchecked`, // выполнены, и поэтому вызов этой функции является безопасным действием. unsafe { self.split_at_mut_unchecked(mid) } } // **NB:** требует, что `mid <= self.len()`. pub unsafe fn split_at_mut_unchecked(&mut self, mid: usize) -> (&mut [T], &mut [T]) { ... // как и ранее. } } |
Небезопасные абстракции и приватность.
Несмотря на то, что в языке нет того, что бы явно связывало
правила приватности и границы небезопасных абстракций, все же они
естественным образом связаны друг с другом. Это из-за того, что приватность
позволяет вам контролировать участок кода, который может изменять
поле в ваших данных, и это является основным строительным элементом,
используемым для построения unsafe
абстракций.
Ранее мы заметили, что тип Vec
в стандартной библиотеке реализован посредством
использования unsafe
кода. Это не было бы возможным без приватности.
Если вы посмотрите на определение Vec
, то увидите,
что оно выглядит подобно этому:
1 2 3 4 5 | pub struct Vec<T> { pointer: *mut T, // указатель на начало выделенной области памяти capacity: usize, // количество выделенной памяти length: usize, // количество инициализированной памяти } |
Код реализации Vec
тщательно поддерживает инвариант, согласно которому
pointer
и первые length
элементов, к которым он обращается, всегда являются допустимыми.
Можно подумать, что если бы length
было открытым (pub) полем, то верхний
инвариант был не возможен: любой вызывающий внешний код мог бы изменить длину
Vec
на произвольную.
Исходя из этой причины, границы «небезопасности» склонны попадать в одну из двух категорий:
- единичные функции, подобные
split_at_mut
- тип, который содержится в своём собственном модуле, например,
Vec
- данный тип, как правильно, имеет приватные вспомогательные функции
- также может содержать вспомогательные функции, которые являются
unsafe
Типы с unsafe
интерфейсами
Как мы видели ранее, иногда может быть полезным создавать unsafe
функции
подобные split_at_mut_unchecked
, которые могут служить строительным блоком для
безопасных абстракций. Это справедливо также и для типов.
Посмотрев на реализацию Vec
из стандартной библиотеки,
вы увидите, что она выглядит подобно приведённому выше коду.
1 2 3 4 | pub struct Vec<T> { buf: RawVec<T>, len: usize, } |
Что это за тип, RawVec
? Выясняется, что это вспомогательный unsafe
тип который
содержит в себе указатель (pointer) и ёмкость (capacity):
1 2 3 4 5 6 | pub struct RawVec<T> { // `Unique` является ещё одним вспомогательным `unsafe` типом, // который обозначает *сырой* указатель с единственным владельцем(uniquely owned). ptr: Unique<T>, cap: usize, } |
Что делает RawVec
вспомогательным unsafe
типом? В отличие от функций,
понятие «unsafe
тип» является довольно размытым. Я определяю такой тип как тип,
который не позволяет вам делать ничего полезного без использования unsafe
кода.
Безопасный (safe) код позволяет конструировать RawVec
, он даже позволяет
изменять размер буфера, который лежит в основе Vec
, но если вы хотите обратиться
к значению, которое находится в данном буфере, вы можете это сделать,
только используя метод ptr
, который возвращает *mut T
. Это «сырой»
указатель, так что его разыменование является unsafe
действием. Это значит,
что для того, чтобы предоставлять полезный функционал, RawVec
должен быть включён в другую
unsafe
абстракцию (подобную Vec
, которая отслеживает инициализацию.
Вывод
unsafe
абстракции являются довольно мощным инструментом. Они позволяют
вам использовать практически любые хитрые приёмы, которые вы только можете
себе вообразить, или использовать одну из возможностей вашей системы, в то же
время имея безопасный и относительно простой язык программирования.
Мы используем «небезопасность» для реализации некоторого числа ключевых абстракций
в стандартной библиотеке, включая такие основные структуры данных как Vec
и Rc
.
Данные абстракции скрывают unsafe
код под безопасным API, поэтому пользователи
данного кода ничем не рискуют.
Как далеко можно зайти?
Одной из вещей, которую я не обсуждал в данной заметке, является то,
что именно можно, а что нельзя делать внутри unsafe
кода.
Очевидно то, что unsafe
кода нужен для того, чтобы обходить правила,
но насколько сильно мы можем их обходить? В настоящий момент
у нас мало выпущенный указаний, касающихся данной темы. Это то, чем мы
хотим заняться’. Был даже RFC, касающийся этой темы, хотя, я думаю, что
предстоит ещё немало поработать, до того как мы придём к окончательным
и ясным выводам.
Как я писал в обсуждении RFC, мы должны стремиться к тому, чтобы
эти правила были легко понимаемыми насколько это только возможно.
В частности, я думаю, что большинство людей не будут читать наши правила,
и ещё меньшее количество попытается их понять. Так что мы должны
способствовать тому, чтобы unsafe
код, который люди будут писать,
пребывая в неведении о правилах, был большей частью правильным.
Здесь есть интересный момент. Чем больше возможностей unsafe
кода допускается
, тем труднее компилятору оптимизировать данный код. Это потому что он в таких
случаях не всегда может точно определять aliasing адресов и не всегда может
переставлять местами выражения (statements reordering).
В моей следующей заметке я опишу свои мысли о том, как мы можем использовать unsafe
абстракции для получения этим самых различных выгод. Проще говоря, предполагается,
что safe-код будет хорошо оптимизирован компилятором, однако, оптимизации не
будут столь явно выражены в unsafe
коде.
Автор перевода: @bmusin.