Небезопасные абстракции

оригинал: Niko Matsakis • перевод: bmusin • обучение • поддержите на Patreon

Ключевое слово 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..])
    }
} 

Данный код не будет скомпилирован по двум причинам:

Можно себе вообразить, что возможно, изменяя компилятор, добиться того, что указанный пример кода будет компилироваться, и, возможно, мы это однажды реализуем. Но в настоящий момент мы предпочитаем реализовывать методы подобные 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 может быть выражена в следующих правилах:

Есть ещё один неупомянутый момент. Нигде не гарантируется, что 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 на произвольную.

Исходя из этой причины, границы «небезопасности» склонны попадать в одну из двух категорий:

Типы с 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.