Rust: безопасность памяти без потерь в скорости
Язык Rust является высокопроизводительным языком программирования, обеспечивающим безопасную работу с памятью. Другие компилируемые языки программирования, например C, могут работать быстро и с минимальным количеством сбоев, но им не хватает функциональных возможностей для обеспечения надлежащего распределения программной памяти. Поэтому, такие языки, как Rust, которые ставят безопасность памяти на первое место, привлекают все больше внимания. В этой статье мы поговорим о том, каким образом Rust гарантирует безопасность памяти.
Встроенная безопасность
Рассказ о безопасной работе с памятью стоит начать с того, что функции безопасности памяти в Rust встроены непосредственно в язык. Они не только обязательны, но и вводятся в действие еще до запуска кода. Дело в том, что во многих языках программирования ошибки связанные с безопасностью памяти, слишком часто обнаруживаются только во время выполнения. В Rust поведение программы, которое не обеспечивает безопасность памяти, рассматривается не как ошибки во время выполнения, а как ошибки компилятора. Таким образом, целые классы проблем, например, ошибки использования после освобождения, будут просто синтаксически неверны в Rust и соответственно, код не будет выполнен.
Конечно, это совершенно не означает, что код, написанный на Rust, является полностью безошибочным. За некоторые проблемы во время выполнения, такие как условия гонки, по-прежнему отвечает разработчик. Состояние гонки — это ошибка проектирования многопоточной системы, при которой ее работа зависит от того, в каком порядке выполняются части кода.
В примере ниже, мы выполняем проверку границ, а затем происходит небезопасный доступ к данным с непроверенным значением:
use std::thread;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
let data = vec![1, 2, 3, 4];
let idx = Arc::new(AtomicUsize::new(0));
let other_idx = idx.clone();
// `move` фиксирует other_idx по значению, перемещая его в этот поток
thread::spawn(move || {
// Можно изменить idx, потому что это значение
// является атомарным, поэтому оно не может вызвать скачок данных.
other_idx.fetch_add(10, Ordering::SeqCst);
});
if idx.load(Ordering::SeqCst) < data.len() {
unsafe {
// Неправильная загрузка idx после того, как мы выполнили проверку границ.
// Это могло измениться. Это условие гонки, * и оно опасно*
// потому что мы решили использовать `get_unchecked`, что является `небезопасным`.
println!("{}", data.get_unchecked(idx.load(Ordering::SeqCst)));
}
}
Эту ошибку компилятор Rust предотвратить не сможет, и программист должен сам сделать свой код неуязвимым для условий гонки. Например, вот так:
use std::thread;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
let data = vec![1, 2, 3, 4];
// Arc, чтобы память, в которой хранится AtomicUsize, все еще существовала для
// увеличения другим потоком, даже если мы полностью завершим выполнение
// до этого. Rust не будет компилировать программу без этого, из-за
// требований к времени жизни thread::spawn!
let idx = Arc::new(AtomicUsize::new(0));
let other_idx = idx.clone();
// `move` фиксирует other_idx по значению, перемещая его в этот поток
thread::spawn(move || {
// Можно изменять idx, потому что это значение
// является атомарным, поэтому оно не может вызвать скачок данных.
other_idx.fetch_add(10, Ordering::SeqCst);
});
// Индексируем со значением, загруженным из atomic. Это безопасно, потому что мы
// считываем атомарную память только один раз, а затем передаем копию этого значения
// в реализацию индексации Vec. Эта индексация будет выполнена корректно
// границы проверены, и нет никаких шансов, что значение изменится
// в середине.
println!("{}", data[idx.load(Ordering::SeqCst)]);
Здесь стоит отметить, что языки с управлением памятью, такие как C#, Java или Python, практически полностью избавляют разработчика от необходимости вручную управлять памятью. Благодаря этому программисты могут полностью сосредоточиться на написании кода и отладке. Но за все надо платить, и за это удобство тоже. В частности, приходится платить другими затратами, такими как, скорость или увеличением времени выполнения. Откомпилированные выполнимые файлы Rust могут быть очень компактными, по умолчанию запускаться со скоростью выполнения машинного кода и оставаться безопасными для памяти.
Давайте посмотрим, как в Rust реализованы эти возможности по безопасной и эффективной работе. Начнем с рассмотрения работы с переменными.
Неизменяемость по умолчанию
В большинстве языков программирования у нас есть константы и есть переменные. Значения констант мы инициализируем, как правило, в начале программы и далее они остаются неизменным. Значения переменных в соответствии с их названием могут изменяться в процессе работы программы.
А в Rust все переменные по умолчанию неизменяемы, то есть их нельзя переназначить или изменить. Для изменения они должны быть специально объявлены как изменяемые.
Например, конструкция следующего вида:
fn main() {
let x = 5;
println!("The value of x is: {x}");
x = 6;
println!("The value of x is: {x}");
}
при компиляции вернет ошибку:
$ cargo run
Compiling variables v0.1.0 (file:///projects/variables) error[E0384]: cannot assign twice to immutable variable `x` --> src/main.rs:4:5
|
2 | let x = 5;
| -
| |
| first assignment to `x`
| help: consider making this binding mutable: `mut x`
3 | println!("The value of x is: {x}");
4 | x = 6;
| ^^^^^ cannot assign twice to immutable variable
Правильный вариант:
fn main() {
let mut x = 5;
println!("The value of x is: {x}");
x = 6;
println!("The value of x is: {x}");
}
Здесь мы явно говорим, что x является изменяемой (mut). Такой подход заставляет разработчиков полностью осознавать, какие значения в программе должны быть изменены и когда. Полученный в результате код легче анализировать, поскольку он сообщает вам, что может измениться и где. Это позволяет минимизировать случайные изменения значений переменных и защищает от ошибок программистов.
Неизменяемость или константа
Посмотрим более подробно, что представляет собой понятие «Неизменяемая по умолчанию» отличается от понятия константы. Неизменяемая переменная может быть вычислена и затем сохранена как неизменяемая во время выполнения, то есть ее можно вычислить, сохранить, а затем не изменять. Однако константа должна быть вычислима во время компиляции, прежде чем программа будет запущена. Многие виды значений — например, введенные пользователем — не могут быть сохранены таким образом в виде констант.
Стоит отметить, что к примеру, в C++ предполагается обратный подход: по умолчанию все изменяемо, и если мы хотим сделать объекты неизменяемыми, необходимо использовать ключевое слово const.
Владение, заимствование и ссылки в Rust
В большинстве языков программирования на объекты нет права собственности и любой другой объект может получить доступ к нему в любое время. Соответственно, вся ответственность за то, как что-то модифицируется, лежит на программисте. В других языках, таких как Python, Java или C#, правил владения не существует, но только потому, что в них нет необходимости. Доступ к объектам и, следовательно, безопасность памяти обрабатываются средой выполнения. Опять же, это достигается за счет скорости или размера и наличия среды выполнения.
А вот в Rust у каждого значения есть «владелец», что означает, что только один объект одновременно, в любой заданной точке кода, может иметь полный контроль над значением для выполнения операций чтения или записи. Право собственности может быть временно передано или «заимствовано», но такое поведение строго отслеживается компилятором Rust. Любой код, нарушающий правила владения для данного объекта, просто не компилируется.
Попробуем разобраться на простом примере. В приведенном ниже примере переменная действительна с момента ее объявления до конца текущей области видимости.
{
// s здесь недействительна, потому что ее еще не объявили
let s = "hello"; // С этого места s действительна
// здесь s тоже действительна
} // а с этого места s больше недействительна
Другими словами, здесь есть два важных момента: когда s попадает в область действия, она становится действительным. И она остается действительным до тех пор, пока не выйдет за пределы области действия.
О времени жизни
Ссылки на значения в Rust имеют не только владельцев, но и время жизни, то есть область, для которой данная ссылка действительна. В большинстве Rust-кода время жизни может быть неявным, поскольку компилятор отслеживает его. Но оно также может быть явно указано для более сложных случаев использования. Как бы то ни было, попытка получить доступ к чему-либо или изменить что-либо за пределами его жизненного цикла или после того, как оно «вышло за пределы области видимости», приводит к ошибке компилятора. Это снова предотвращает внедрение целых классов опасных ошибок в рабочий код Rust.
Так ошибки использования после освобождения или «висячие указатели» возникают, когда вы пытаетесь получить доступ к чему-то, что теоретически было освобождено или вышло за пределы области видимости. Это удручающе распространено в C и C++. C не имеет официального контроля времени жизни объекта во время компиляции. В C++ есть такие понятия, как «умные указатели», позволяющие избежать этого, но они не реализованы по умолчанию; вы должны согласиться на их использование. Безопасность языка становится вопросом индивидуального стиля программирования или институциональных требований, а не чем-то, что язык обеспечивает в целом.
В Rust, срок службы переменной начинается с момента ее создания и заканчивается при уничтожении. Хотя время жизни и области часто используются вместе, это не одно и то же. Возьмем, к примеру, случай, когда мы заимствуем переменную с помощью &. Время жизни переменной определяется тем, где она объявлена. В результате заимствование действительно до тех пор, пока оно не закончится до того, как заемщик будет уничтожен.
Посмотрим пример:
fn main() {
let i = 3; // Начало времени жизни `i`
{
let borrow1 = &i; // Начало времени жизни `borrow1`
println!("borrow1: {}", borrow1);
} // Окончание времени жизни `borrow1`
{
let borrow2 = &i; // Начало времени жизни `borrow2`
println!("borrow2: {}", borrow2);
} // Окончание времени жизни `borrow2`
} // Окончание времени жизни `i`
В этом примере мы видим различные варианты использования времени жизни как при объявлении переменных, так и при заимствованиях.
Заключение
Мы рассмотрели некоторые конструкции Rust, используемые для обеспечения безопасной работы с памятью без существенных потерь в скорости. Но за все приходится платить. Освоение этих конструкций может быть сопряжено с трудностями, связанными с пониманием принципов работы этих механизмов языка Rust. Однако, когда вы разберетесь в них, вы сможете существенно повысить безопасность разрабатываемого кода.
Хочу пригласить вас на бесплатные вебинары курса Rust Developer. Professional: