Как стать автором
Обновить
938.8
OTUS
Цифровые навыки от ведущих экспертов

Как работает управление памятью в Rust без сборщика мусора

Уровень сложностиСредний
Время на прочтение8 мин
Количество просмотров9.9K

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

Основные концепции

Владение (Ownership)

Владение — это основной принцип управления памятью в Rust, где каждое значение в Rust имеет переменную, называемую его владельцем.

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

fn main() {
    let string1 = String::from("hello");
    let string2 = string1;

    // println!("{}, world!", string1); // Это вызовет ошибку компиляции
    println!("{}, world!", string2);
}

string1 владеет строковым значением. Когда string1 передается переменной string2, владение строкой переходит к string2. После этого string1 больше не имеет доступа к строке.

Заимствование (Borrowing)

Заимствование в Rust позволяет использовать данные, находящиеся во владении другой переменной, без перехода владения.

fn main() {
    let string1 = String::from("hello");
    let length = calculate_length(&string1);

    println!("The length of '{}' is {}.", string1, length);
}

fn calculate_length(s: &String) -> usize {
    s.len()
}

&string1 создает ссылку на string1, которую мы передаем в функцию. Поскольку ссылка не владеет значением, string1 остается действительной после вызова функции, и мы можем снова её использовать в println!.

Rust различает два типа заимствований: неизменяемое (immutable) и изменяемое (mutable) заимствование. Неизменяемое заимствование позволяет иметь множество ссылок на данные, но запрещает их изменение, в то время как изменяемое заимствование позволяет изменять данные, но только через одну ссылку.

Время жизни (Lifetimes)

Время жизни – это аннотации, которые программист использует для указания Rust, как долго ссылка на данные должна оставаться действительной.

Цель времени жизни – гарантировать, что ссылки всегда указывают на действительные данные.

fn longest<'a>(string1: &'a str, string2: &'a str) -> &'a str {
    if string1.len() > string2.len() {
        string1
    } else {
        string2
    }
}

fn main() {
    let string1 = String::from("abcd");
    let string2 = "xyz";

    let result = longest(string1.as_str(), string2);
    println!("The longest string is {}", result);
}

Аннотация времени жизни 'a гарантирует, что обе возвращаемые ссылки будут жить как минимум столько же, сколько и string1. В коде'a устанавливает, что возвращаемая ссылка будет жить столько же, сколько живет самая короткая из двух входных ссылок.

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

Срезы (Slices)

Срез — это ссылка на участок памяти. Это может быть часть массива или всё его содержимое. То есть, когда ты работаешь с массивом, ты можешь получить срез этого массива, чтобы работать только с его частью, не копируя при этом весь массив. Экономия ресурсов и времени – это наше все...

Самое замечательное в срезах — они безопасны. В Rust вся работа с памятью проверяется на этапе компиляции, так что риск ошибок минимален. Неизменяемые срезы позволяют только читать данные, а изменяемые — и читать, и изменять.

fn main() {
    let arr = [1, 2, 3, 4, 5];
    let slice = &arr[1..4]; // 
    println!("{:?}", slice);
}

&arr[1..4] создает срез, который включает элементы с индексами 1, 2 и 3 из массива arr.

Scope

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

fn main() {
    { // начало нового scope
        let local_var = 3;
        println!("Значение переменной в scope: {}", local_var);
    } // конец scope - local_var освобождается здесь

    // println!("Попытка доступа к local_var: {}", local_var); // Ошибка! local_var здесь уже не существует
}

local_var освобождается сразу после выхода из своего scope

Усложним код, добавив структуры:

struct MyStruct {
    data: i32,
}

fn main() {
    {
        let my_struct = MyStruct { data: 5 };
        println!("Значение data в my_struct: {}", my_struct.data);
    } // my_struct удаляется здесь, и память, выделенная под data, также освобождается

    // попытка доступа к my_struct.data здесь вызовет ошибку компиляции
}

my_struct и его данные автоматически удаляются, когда мы выходим из scope

Мы можем использовать функции для создания новых scopes:

fn process_data() {
    let temp_data = 10; // temp_data имеет scope внутри этой функции
    println!("Обработка temp_data: {}", temp_data);
} // temp_data освобождается здесь

fn main() {
    process_data();
    // temp_data уже не доступна здесь
}

process_data создает отдельный scope для temp_data, что обеспечивает ее автоматическое освобождение после выполнения функции.

Box, указатели и небезопасные блоки кода

Box

В отличие от обычных переменных, которые выделяются на стеке, Box позволяет выделить память на куче. Как только Box выходит из области видимости, его деструктор автоматически освобождает выделенную память на куче.

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

Box может использоваться для создания абстракций, например, когда вы работаете с трейтами. Он позволяет обрабатывать различные типы данных через один и тот же интерфейс.

fn main() {
    let b = Box::new(5); // выделение памяти для числа 5 на куче
    println!("b = {}", b); // b автоматически освободит память, когда выйдет из области видимости
}

Указатели

В Rust есть два вида сырых указателей (raw pointers): *const T для неизменяемого доступа и *mut T для изменяемого. Они похожи на указатели в языках C/C++, но без гарантий безопасности, что Rust обычно предоставляет. Использование сырых указателей нужно когда ты хочешь вручную управлять памятью или взаимодействовать с C кодом.

Пример использования сырых указателей:

let mut x = 10;
let x_ptr = &mut x as *mut i32;

Небезопасные блоки кода

unsafe code– это специальный блок кода, который позволяет обходить стандартные проверки безопасности Rust. Это полезно когда вам нужно напрямую работать с памятью, например, через указател, если вы работаете с кодом на C или других языках, которые не следуют правилам безопасности Rust,когда нужно обойти некоторые ограничения компилятора, связанные с типами данных.

Этот блок дает доступ к 5 конкретным действиям и не выключает полностью проверки Rust. Подробнее описано в комментарии @Gadd к этой статье.

К примеру простойunsafe блок:

unsafe {
    println!("Absolute value of -3 according to C: {}", abs(-3));
}

Небезопасный код используется только тогда, когда иного выхода нет.

Stack и heap

Stack

Stack - это структура данных, работающая по принципу LIFO. Последний элемент, который был помещен в стек, будет первым извлеченным. В Rust все данные размер которых известен на этапе компиляции и не изменяется во время выполнения программы, обычно хранятся на стеке. Это включает примитивные типы (как i32, f64), фиксированные массивы, кортежи с элементами известного размера и структуры без динамических элементов.

Доступ к данным на стеке происходит очень быстро. Поскольку стек управляет памятью по принципу LIFO, не требуется сложного управления памятью, как в случае с heap.

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

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

Поскольку стек управляется последовательно, выделение и освобождение памяти на стеке происходит очень быстро.

fn main() {
    let x = 10; // переменная x помещается на стек
    let y = 20; // переменная y помещается на стек

    let result = sum(x, y); // вызов функции sum
    println!("Результат: {}", result);
}

fn sum(a: i32, b: i32) -> i32 {
    let sum = a + b; // переменная sum помещается на стек внутри функции
    sum // возвращается значение sum и оно удаляется со стека
}

x, y и sum хранятся на стеке. При вызове функции sum, её параметры и локальные переменные также размещаются на стеке и автоматически удаляются после завершения функции

Heap

Heap — это область памяти, используемая для динамического выделения памяти, где размер данных может изменяться во время выполнения программы. В отличие от стека, куча не организована в строгом порядке, и доступ к её элементам обычно медленнее по сравнению со стеком.

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

Доступ к данным в куче осуществляется через указатели. Когда выделяется память в куче, мы получаем указатель на эту память. Это отличается от стека, где вы работаете непосредственно с значениями.

В Rust, память в куче автоматически освобождается, когда последний владелец данных выходит из области видимости, благодаря механизму RAII.

Box для выделения данных в куче:

fn main() {
    let b = Box::new(10); // выделяем целое число в куче
    println!("b = {}", b);
} // b выходит из области видимости, и память автоматически освобождается

Создание динамического массива (вектора):

fn main() {
    let mut v = Vec::new(); // динамический массив, выделенный в куче
    v.push(1);
    v.push(2);
    v.push(3);
    println!("v = {:?}", v);
} // вектор v выходит из области видимости, и память автоматически освобождается

Типы данных

Примитивные типы, такие как целые числа (i32, u32), числа с плавающей точкой (f64, f32), логический тип (bool), и символы (char). Они обычно хранятся на стеке. Освобождение памяти происходит автоматически, когда переменная выходит из своего scope.

Сложные типы, такие как структуры (struct) и перечисления (enum) используются для создания сложных типов данных. Память для этих типов может быть выделена как на стеке, так и на куче, в зависимости от их использования и содержания. Rust автоматически управляет этой памятью, следуя правилам ownership и borrowing.

Строки string - это изменяемый тип данных, хранящийся на куче. &str представляет собой неизменяемую ссылку на строку. String требует явного освобождения памяти и может привести к утечкам памяти, если не управлять ей правильно. В то время как &str, будучи ссылкой, управляется автоматически.

Векторы - это изменяемые списки, способные хранить элементы одного типа. Векторы динамически управляют памятью на куче для своих элементов. Память автоматически освобождается, когда Vec<T> выходит из области видимости, благодаря механизмам Rust по управлению памятью.

Указатели и смарт-указатели используются для более сложного управления памятью и владения. Box<T> используется для выделения значения на куче. Rc<T> и Arc<T> представляют собой счетчики ссылок для обеспечения безопасного разделения владения между несколькими частями кода.


Чего ожидать от Rust в 2024 году

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

Одним из важных направлений разработки Rust 2024 является улучшение поддержки асинхронного кода. Ранее в Rust были внесены изменения, позволяющие методам трейтов возвращать опаковые типы (opaque types), такие как -> impl Trait. Это позволило упростить написание асинхронных функций в трейтах, не требуя оборачивать анонимные структуры, возвращаемые компилятором, в кучу с использованием виртуальных таблиц. В ближайшем будущем планируется добавление поддержки асинхронных итераторов и, возможно, асинхронных деструкторов, что будет большим шагом вперед в развитии асинхронного программирования в Rust.

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

Такие изменения могут привести к более выразительному управлению памятью в Rust!

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

Теги:
Хабы:
Всего голосов 41: ↑37 и ↓4+33
Комментарии5

Публикации

Информация

Сайт
otus.ru
Дата регистрации
Дата основания
Численность
101–200 человек
Местоположение
Россия
Представитель
OTUS