Понимание, а не догадки

Перевод | Автор оригинала: Ana Hoverbear

Развитие того, как мы изучаем системы, на уроках программирования в целом

Некоторые ошибки просто разовые. Своенравный мотылек, который просто случайно порхает через не то реле в неподходящее время. Но некоторые ошибки не такие. Вместо этого они достигли статуса суперзвезд, одинаково беспокоя как ветеранов, так и новичков. Но что, если это вовсе не ошибки? Что, если это реальные недостатки в безопасности и надежности, предлагаемые языком программирования C, как следствие того, в какой степени вводятся догадки. Здесь мы исследуем более явный подход к программированию на системном уровне, поддерживаемый Rust, который, как мы полагаем, будет лучше способствовать пониманию замысла проекта и избавит от некоторых догадок. Руководствуясь набором классических, но все еще актуальных ошибок, выявленных Энглером почти 15 лет назад, мы рассматриваем это в контексте нового поколения студентов, изучающих системы в рамках типичного курса ОС, где студенты часто впервые сталкиваются с этими недостатками.

1. Введение

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

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

Помимо сложности, неоднозначность языковых функций означает, что год за годом новые студенты в конечном итоге сталкиваются с одними и теми же старыми ошибками +++. Энглер и др. (ref) в своей работе по статическому анализу определили ряд классов проблем, которые послужили основой для многих инструментов в системах (ref), (ref), приложениях (ref), (ref) и даже расширениях компилятора, полученных из технических характеристик оборудования. (исх.). И студентов, и профессионалов озадачивают, казалось бы, простые вопросы, такие как:

В этой короткой статье мы точно продемонстрируем, как Rust устраняет эти потенциальные ошибки ясным, чистым, безопасным и надежным образом. После знакомства с Rust (раздел 2) мы обсудим, как Rust подходит и помогает решить эти общие категории ошибок (раздел 3). Мы также обсуждаем цели «Безопасность», состояние инструментария и сообщество Rust (Раздел 4), прежде чем закончить с Future Work (Раздел 5).

2. Представляем Rust

Rust (ref) - это системно-ориентированный язык семейства ML, поддерживаемый Mozilla Research. Первоначально он был разработан Грейдоном Хоаром, а первый стабильный выпуск был выпущен 15 мая 2015 г. (ref). Это Apache и MIT с двойной лицензией, полностью открытый исходный код и обширный процесс запроса комментариев (RFC).

Rust предлагает надежный набор желаемых функций для системного кода: опережающая компиляция, абстракции с нулевой стоимостью, семантика перемещения, гарантированная безопасность памяти, потоки без гонок данных, обобщения на основе трэйтов, сопоставление с образцом, вывод типов, минимальное время выполнения ( съемный, (ref)), сборщик мусора или виртуальная машина не требуется, эффективные привязки C и надежный статический анализ

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

2.1 Основы Rust

Тем, кто знаком с C/C++, синтаксис Rust покажется достаточно знакомым. Однако Rust во многом отличается: он считает, что лучше быть явным и способствовать пониманию происходящего, чем ожидать, что программист будет держать всю эту информацию в своей голове и гадать.

Это ключевой мотивирующий фактор, лежащий в основе предлагаемого нами внедрения Rust в курсы по ОС. Мы считаем, что это качество не отменяет краткости или элегантности кода. Члены сообщества разработали привязки для известных инструментов, таких как Redis (ссылка), и нашли API-интерфейсы для эквивалентных действий Rust и Python с относительно похожим «ощущением», несмотря на преимущества системы типов Rust, обеспечивающей дополнительную защиту (ссылка).

Однако Rust имеет значительные семантические различия по сравнению с C-подобными языками. Для объявления переменных в Rust есть ключевое слово let, которое по умолчанию неизменяемо, а изменчивость - через let mut. Сообщество обнаружило, что такая изменяемость согласия поощряет лучший код. Вместо того, чтобы программисту нужно было помнить об использовании const, компилятор информирует их о любых переменных, которые они могли забыть сделать изменяемыми, или если они являются изменяемыми без необходимости.

Кроме того, определения функций отличаются от C-подобных языков. Это изменение упрощает понимание определений функций при работе со сложными параметрами, универсальными шаблонами и возвращаемыми значениями. Многочисленные доводы в пользу того, почему синтаксис объявления C неадекватен, были хорошо объяснены Робом Пайком (ссылка).

fn example_simple()
fn example_params(x: u64, y: &u64, z: &mut u64)
fn example_returns(x: u64) -> u64
fn example_generic<U: Read>(reader: U) -> u64
fn example_generic_alt<U>(reader: U) -> u64
    where U: Read

2.2 Сильная система типов

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

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

Типы могут быть легко созданы, и есть три основных составных структуры данных: структура, перечисление и кортежи. структуры и кортежи похожи на другие языки. Перечисления Rust могут представлять варианты с инкапсулированными значениями, обобщениями и даже структурами!

// Structure with generic
struct One<T> {
    foo: usize,
    bar: T
}
// 2-tuple
struct Two(usize, usize);
// Enum
enum Three {
    // Plain.
    Foo,
    // Variant with Tuple.
    Bar(usize),
    // Variant with Struct.
    Baz { x: u64, y: u64, z: u64, },
}

2.3 Нам не нужен нуль

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

В таких языках, как C, C++ и Java, огромное количество времени на исследования и разработки ушло на разработку таких продуктов, как Coverity (ссылка) и PVS-Studio (ссылка), чтобы помочь обнаружить возможные несоответствия нулевого указателя. Энглер и др. Предлагают эвристические методы для определения «нулевого состояния» переменной во всем потоке управления программы. Что, если бы программисты могли просто перестать беспокоиться о null вместе?

Многие функциональные языки, такие как Haskell и F #, имеют концепцию Option, которую разделяет Rust. Вместо того, чтобы знать и проверять наличие null в каждом случае, семантика языка требует от программиста явного выбора потока управления для всех значений.

// Create a `Some(T)` and a None.
let maybe_foo = Some(0);
let not_foo = None;
// Unwrapping.
let foo = maybe_foo.unwrap();
let default = not_foo.unwrap_or(1);
let matched = match maybe_foo {
    Some(x) => x,
    None => -1,
};
// Mapping
let mapped = maybe_foo.map(|x| x as f64);

3. Rust: сокращение количества ошибок за счет лингвистических функций

В Rust можно предотвратить многие распространенные ошибки, потому что: подпрограммы с потенциальной ошибкой несут это явно в своей сигнатуре функции (раздел 3.1), RAII используется для обеспечения того, чтобы выделения сопровождались освобождением (раздел 3.2), проверки безопасности могут потребоваться система типов или свойства маркеров для «испорченных» данных (раздел 3.3), мощная семантика перемещения исключает ошибки использования после освобождения (раздел 3.4) и блокировки по сути защищают данные, а не код (раздел 3.4).

3.1 Результаты и попробуйте!()

При работе с традиционными языками, такими как C и C++, часто бывает сложно ответить на вопрос «Может ли эта функция дать сбой?» Проверенные исключения могут помочь, но часто API-интерфейсы несовместимы, и о проверках ошибок можно забыть (см.). Некоторые методы статического анализа могут использоваться для определения возможных пропущенных проверок сбоев, например, при вызовах обнаружения, которые действительно проверяют наличие ошибок. Однако включение информации об отказе в сигнатуру функции и требование ее явной проверки может быть более надежным решением по сравнению с эвристикой.

Перечисление Result<T, E> существует как Ok(T) или Err(E) и передает результат чего-то, что может завершиться ошибкой. Используя выражение match в Rust, пользователь может действовать при различных ошибках или успехе.

use std::io;
use std::error::Error;
// Create an error. (Normally raised from lib)
let error = io::Error::new(io::ErrorKind::Other,
    "I'm an example error!");
// The two result variants. Type notations usually
// not necessary except in small examples.
let success: Result<_, io::Error> = Ok("Success!");
let failure: Result<&str, _> = Err(error);
// Return either the value or the error description.
let val_or_desc = match success {
    Ok(val) => val,
    Err(ref e)  => e.description(),
};

Это предупреждение компилятора выполнить такое действие, как file.read_to_string (buf), которое возвращает Result<usize, Error>, и не обрабатывать ошибку каким-либо образом. В Rust идиоматична передача любой исправляемой ошибки в стек вызовов туда, где ее можно разумно обработать. Приближаясь к этой идее, новички обычно борются с тем фактом, что io::Error и Utf8Error являются разными типами и не могут быть возвращены в одном и том же Result<T, E>, поскольку значение E будет отличаться и нарушать строгую типизацию Rust. Обычно это решается путем создания новой ошибки, которая представляет собой перечисление возможных скрытых ошибок, а также любых ошибок, которые программист может пожелать включить в себя. Затем есть трэйты Into и From, которые могут быть реализованы для обеспечения непрерывного взаимодействия.

pub enum MyError {
    Io(io::Error),
    Utf8(Utf8Error)
}
impl From<io::Error> for MyError {
    fn from(err: io::Error) -> Error {
        Error::Io(err)
    }
}
// ...

При работе с функциями, которые могут возвращать Result<T, E>, обычно используется макрос try!(). Этот макрос расширяется, чтобы либо развернуть значение T внутри и присвоить его, либо вернуть ошибку вверх по стеку вызовов. Это помогает уменьшить визуальный «шум» и помогает в композиции.

fn open_and_read() -> Result<String, MyError> {
    let mut f = try!(File::open("foo.txt"));
    let mut s = String::new();
    let num_read = try!(f.read_to_string(&mut s));
    Ok(s)
}

Обработка ошибок в Rust является явной, компонуемой и разумной. Нет никаких исключений, нулей, «магических чисел» (например, -1) или чего-либо, что может помешать программисту обрабатывать ошибку по своему усмотрению, даже если это просто .unwrap() и сбой. Стоит отметить, что даже .unwrap() ing на самом деле не приводит к сбою программы, поскольку обычно он раскручивает стек, изолируя сбой до одного потока и предотвращая несогласованное состояние.

3.2 Заимствование и перемещение: забудьте о бесплатном()

В Rust есть понятия перемещения, копирования и ссылки. В некотором смысле модель памяти Rust похожа на C/C++. Он имеет мощную систему указателей, которая позволяет программистам принимать точные, обоснованные решения о том, как значения хранятся, передаются и представляются. Как и C++, Rust использует концепцию под названием Resource Acquisition Is Instantiation (RAII). Rust идет еще дальше, вводя различие между неизменным заимствованием (&), взаимным заимствованием (&mut), копированием (свойство Copy) и перемещением значений. В любой момент времени может быть любое количество неизменяемых заимствований, при этом может быть только одно изменяемое заимствование, и значение не может использоваться в функции после того, как оно было перемещено.

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

fn main() {
    // An owned, growable,
    // non-copyable string.
    let mut foo = String::from("foo");

    // Introduce a new scope.
    {
        // Reference bar is created.
        let bar = &foo;
        // Error, bar is immutable.
        bar.push('c');
    } // bar is destroyed.

    // Error, bar does not exist.
    let baz = bar;
    // Works, reference mutable.
    let rad = &mut foo;
    rad.push('c');
} // foo is destroyed.

Это поведение очень похоже на возможности RAII в C++ и гарантирует, что все значения будут безопасно уничтожены последовательным и предсказуемым образом, как только они больше не будут нужны. Программисту не нужно беспокоиться о том, чтобы каждый из его вызовов malloc() имел соответствующий free(), или полагаться на внешний инструмент (ref) для обнаружения таких ошибок. Средство проверки заимствований также может определить, когда значение было перемещено в вызов функции и не должно использоваться в дальнейшем в вызывающей программе, что устраняет другой возможный класс ошибок.

3.3 Трэйты характера: Абстракции с нулевой стоимостью

Rust не использует систему, основанную на классах или наследовании. Данные хранятся в структурах, примитивах или перечислениях, которые реализуют набор характеристик, определяющих, как они взаимодействуют и какие функции им доступны. Например, File - это структура, которая, среди прочего, реализует Read и Write. Другие структуры, такие как TcpStream и UdpSocket, также реализуют ту же трэйту Read и Write. Трэйты - это абстракции с нулевой стоимостью, которые поощряют общие интерфейсы и возможности между подобными структурами (ссылка).

struct Thing {
    barred: bool,
}
trait Foo {
    // Implementor must define.
    fn bar(&mut self);
    // Default definition.
    fn do_bar(&mut self) { self.bar() }
}
impl Foo for Thing {
    fn bar(&mut self) {
        self.barred = true;
    }
}

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

3.4 Статический анализ в ядре

Инструменты статического анализа, такие как splint для C (ref), являются бесценным инструментом для программирования операционных систем, особенно при работе с большими кодовыми базами с несколькими программистами. Система типов и память на основе регионов в Rust, основанная на Cyclone (ссылка), особенно хорошо подходят для статического анализа. В самом деле, rustc сам выполняет огромный объем статического анализа без помощи внешних инструментов. Система типов несет всю информацию, необходимую компилятору для понимания всех возможных потоков управления программой, всех возможных (исправимых) ошибок, которые возникают, и времени жизни каждой области памяти.

Особый интерес представляет "Borrow Checker" от rustc, который анализирует и понимает систему указателей и может проверять безопасность данных даже в нескольких потоках. Проверка заимствований - это область активных исследований (исх.). В результате статического анализа, выполненного rustc, он может вывести информацию о (но не ограничиваясь этим):

3.5 Нити, которые не кусаются

Поточность, пожалуй, одна из самых мощных и надежных функций Rust. Характеристики, описанные выше, при смелом использовании в многопоточном контексте достигают высшей точки в своего рода «tour de force».

Используя возможности семантики владения, системы типов, модулей потоковой передачи стандартной библиотеки, доступен ряд инструментов (ссылка):

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

use std::sync::mpsc::{channel, Sender, Receiver};
let (send, receive) = channel();

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

use std::sync::{Arc, Mutex};
let data = Arc::new(Mutex::new(0));

Такие трэйты, как Sync и Send, реализованы в типах и символизируют возможность безопасной отправки или совместного использования между потоками. Эти трэйты - не просто документация, они являются неотъемлемой частью языка.

// Safe to share between threads.
use std::marker::Sync;
// Safe to transfer between threads.
use std::marker::Send;

Примитивы параллелизма в Rust являются мощными и компонуемыми, что позволяет пользователям реализовывать другие, более бесстрашные формы параллелизма, такие как совместное использование фреймов стека. Например, вот демонстрация стороннего крэйта, называемого crossbeam, который позволяет нам безопасно работать одновременно с данными, выделенными в стеке: (ref)

fn main() {
    let items = [1, 2, 3];

    crossbeam::scope(|scope| {
        for item in &items {
            scope.spawn(move || {
                println!("{}", item);
            });
        }
    });
}

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

4. Безопасность, инструменты и сообщество

Понятие «безопасность» в коде часто плохо определяется, но его можно разделить на три категории:

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

4.1 Инструменты

У Rust есть надежный и упорядоченный набор инструментов. Стандартный дистрибутив Rust включает rustc (компилятор), cargo (менеджер пакетов и инструмент сборки) и rustdoc (генератор документации). В настоящее время ведется работа над rustfmt, который будет работать так же, как почтенный gofmt Go.

Управление пакетами через Cargo - это функция, которую Rust унаследовал от нескольких других современных языков. Все зависимости пакетов, параметры сборки и задачи определены в файле Cargo.toml. Зависимости проверяются и (при необходимости) извлекаются из сборки cargo, теста или документации.

Rust по умолчанию поддерживает как модульные, так и интеграционные тесты. Модульные тесты могут появляться везде, где это уместно в коде, и помечены #[test], дизайнеры обычно включают тестовый модуль в свой код. Интеграционные тесты написаны в каталоге tests / и позволяют тестировать пакет как зависимый от библиотеки. Тестирование выполняется простым вызовом Cargo test в каталоге проекта. Эти функции устраняют барьеры, с которыми программисты могут столкнуться при работе с другими языками, которые не позволят им потрудиться над тестированием. Кроме того, он упрощает маркировку проектов, основанных на Rust, все, что нужно сделать инструктору, - это предоставить (или заменить) каталог tests / на соответствующий набор.

#[test]
fn test_passes() {
    assert_eq!(true, true);
}
#[test]
#[should_panic]
fn test_fails() {
    assert!(true == false);
}

Наличие стандартизированного высококачественного формата документации неоценимо для программистов, и Rust облегчает это. Комментарии к документации можно разместить в любом месте кода, используя /// для документации на уровне функций или //! для документации на уровне модуля. Документация имеет общий формат уценки, примеры кода, включенные в документацию, автоматически обрабатываются как модульные тесты. Создание документации осуществляется с помощью Cargo doc, который генерирует документацию в формате HTML и man-страницы. Многие проекты Rust доходят до того, что автоматизируют этап модульного тестирования и генерации документации и подключают его к своим коммитам git (ссылка).

4.2 Сообщество

Одна из самых больших опасностей при выборе языка, который "не является C" для обучения операционным системам, заключается в том, что студентам может быть очень трудно получить помощь. IRC-сеть Mozilla является хостом популярного канала #rust, у которого в любой момент времени регулярно насчитывается более 800 участников. crates.io размещает более 2300 пакетов. 15 мая 2015 года язык достиг версии 1.0 (ссылка) и находится в разработке с 2006 года. Сообщество активно и дружно с различными группами по интересам.

Лучше всего то, что в Rust идет активная разработка операционной системы. Существует проект по разработке coreutils (ссылка), ядра (ссылка), операционных систем (ссылка) и платформ встроенных систем (ссылка). На момент написания эти проекты достаточно молоды, чтобы студенты могли даже вносить компоненты в апстрим.

5.0 Заключение и дальнейшая работа

В этой работе мы рассмотрели некоторые причины рассматривать Rust как язык для нового поколения системных программистов, подчеркнув, как именно Rust предотвращает классические ошибки. Остается еще много исследований относительно использования Rust в системном коде и программировании в целом. Мы стремимся способствовать изучению языка в Университете Виктории и работаем над разработкой распределенных алгоритмов консенсуса, таких как Raft (ссылка), и систем инициализации нового поколения в духе OpenRC.