Pull to refresh

Rust: «Небезопасные абстракции»

Reading time13 min
Views7.7K
Original author: Niko Matsakis

Ключевое слово unsafe является неотъемлемой частью дизайна языка Rust. Для тех кто не знаком с ним: unsafe — это ключевое слово, которое, говоря простым языком, является способом обойти проверку типов (type checking) Rust'а.


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


Но не все так просто, детали — под катом.


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


unsafe код добавляет 3 возможности:


  1. Чтение и запись статической изменяемой(static mutable) переменной
    В С такая переменная обозначается extern.
    Так как к переменной возможно одновременное обращение из нескольким потоков,
    то возникает состояние гонки(race condition), когда обращение к переменной не синхронизировано.
    Rust по-умолчанию предотвращает это, и чтобы обойти это ограничение, используется unsafe код.

static mut N: i32 = 1;

fn add_one(n: i32) -> i32 {
    n + 1
}

fn main() {
    unsafe {
        N = add_one(N); // пишем
    }

    // что-то еще выполняем

    unsafe {
        println!("{}", N); // читаем
    }
}

  1. Разыменование сырого(raw) указателя
    Компилятор не знает заранее, куда указывает указатель.
    Ответственность берет на себя программист, которые проверяет
    на то, что значение указателя указывает на память, обращение к которой допустимо.

fn add_one_ptr(n: *mut i32) {
    unsafe {
        *n = *n + 1;
    }
}

fn main() {
    let mut n = 5;

    add_one_ptr(&mut n as *mut i32); // пишем

    // что-то еще выполняем

    // safe код, ибо n - не static mutable
    // и мы ничего не разыменовываем
    println!("{}", n); // читаем
}

А вот такой код вызовет segmentation fault:


unsafe {
        let ptr = 0 as *mut i32;
        *ptr = 1;
}

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

Вот так:


unsafe fn do_dangerous_thing() {
    println!("{}", "in `unsafe` code");
}

fn main() {
    unsafe {
        do_dangerous_thing();
    }
}

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


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, который мы видели в прошлом разделе. Для упрощения понимания, мы будем рассматривать только внешний интерфейс функции, представляемый сигнатурой:


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) указатели:


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 всегда выполняется, даже в оптимизированном коде):


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_mutsplit_at_mut_unchecked — который не проверяет нахождение mid в допустимых границах:


 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)
     }
 }    

Когда fn объявлена как unsafe подобно тому, как это сделано выше, вызов ее тоже становится unsafe. Это значит, что человек, который пишет вызывающий код, должен ознакомиться с документацией функции и убедиться, чтобы все условия соблюдены.
А в данном конкретном случае вызывающий код должен убедиться, что mid <= self.len().


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


Используя split_at_mut_unchecked, мы можем изменить реализацию split_at_mut так, чтобы она внутри себя, проводя необходимые проверки, вызывала split_at_mut_unchecked:


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, то увидите, что оно выглядит подобно этому:


pub struct Vec<T> {
    pointer: *mut T, // указатель на начало выделенной области памяти
    capacity: usize, // количество выделенной памяти
    length: usize, // количество инициализированной памяти
}

Код реализации Vec тщательно поддерживает инвариант, согласно которому pointer и первые length элементов, к которым он обращается, всегда являются допустимыми. Можно подумать, что если бы length было открытым (pub) полем, то верхний инвариант был не возможен: любой вызывающий внешний код мог бы изменить длину Vec на произвольную.


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


  • единичные функции, подобные split_at_mut
  • тип, который содержится в своём собственном модуле, например, Vec
    • данный тип, как правильно, имеет приватные вcпомогательные функции
    • также может содержать вспомогательные функции, которые являются unsafe

Типы с unsafe интерфейсами


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


pub struct Vec<T> {
    buf: RawVec<T>,
    len: usize,
}

Что это за тип, RawVec? Выясняется, что это вспомогательный unsafe тип который содержит в себе указатель (pointer) и емкость (capacity):


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 коде.


Большое спасибо всем из сообщества Rustycrate, кто участвовал в переводе, вычитке и редактировании данной статьи. А именно: born2lose, ozkriff, vitvakatu.


UPD: добавил описание 3 возможностей unsafe кода.

Only registered users can participate in poll. Log in, please.
Часто ли вы используете `unsafe` код на Rust?
20% Нет, не использую5
12% Только для реализации структур данных вроде двусвязного списка3
52% Время от времени, но без фанатизма13
0% Только на нем и пишу0
16% А зачем? Для этого есть C.4
25 users voted. 15 users abstained.
Tags:
Hubs:
Total votes 39: ↑39 and ↓0+39
Comments9

Articles