AI Как работает система владений и ссылок в Rust на низком уровне

AI

Редактор
Регистрация
23 Август 2023
Сообщения
2 959
Лучшие ответы
0
Реакции
0
Баллы
51
Offline
#1


Недавно читая хабр и смотря на вечные баталии C++ и Rust разработчиков я подумал что-то вроде "А так ли хорошо управление памятью в Rust как о нем говорят?". С этим сегодня мы и попробуем разобраться.

Не будем тянуть, Rust разработчики постоянно говорят о необычный системе "владения" (ownership) которая и управляет памятью в этом языке. Она необычная, пусть работать с ней не сильно сложно, первое время осознавать отличия было достаточно больно. (Для меня как сишника точно). В предпросмотре я упоминал плюсы но в статье их примеров не будет, в них все вроде итак понятно, просто руками выделяешь и освобождаешь память. Я не буду судить что хуже или лучше, как никак это две абсолютно разных технологии которые используются для достижения разных целей.

Что из себя представляет данная система?


Система владения и жизненных циклов в Rust - одно из основных отличий от других языков и главный апостол его memory-safe повадок. Ее суть кроется в трех основных понятиях: ссылках, жизненных циклах и владении. Она разработана для того чтобы писать безопасный для памяти код без сборщика мусор и без ручных malloc() и free()

Правила концепции:


  • Каждое значение имеет владельца — переменную, которая отвечает за освобождение ресурса.


  • В каждый момент времени у ресурса может быть только один владелец.


  • Когда владелец выходит из области видимости, ресурс освобождается.

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

❯ Правило первое, владение


fn main() {
let greet = String::from("Привет Хабр!");
println!(greet);
}

  • Переменная greet - владелец строки


  • Когда закрывается фигурная скобка (greet выходит из области видимости) память автоматически освобождается. Но это не сборщик мусора, просто вызов drop(greet)
❯ Правило второе, один владелец на одно время


fn main() {
let s1 = String::from("hello");
let s2 = s1; // s1 теперь недействителен.

println!("{}", s2);
// println!("{}", s1); -> s1 больше не владелец, ошибка
} // drop(s1);

Ничего сложного, то есть тут переменная передается другому владельцу, а не копируется. К примеру в Python в аналогичном коде вывод был бы два раза hello. Предотвращается двойное освобождение памяти, s1 больше использовать нельзя. Но вообще клонировать тоже можно.

❯ Правило третье, заимствование


fn print_length(s: &String) {
println!("Length: {}", s.len());
}

fn main() {
let s = String::from("hello");
print_length(&s);
println!("{}", s); // s всё ещё действителен
}

Это по сути указатели, просто интегрированные во всю эту систему


  • &s - это указатель, он памятью не владеет


  • После вызова print_length владелец (s) остается жив.


  • Гарантируется что ссылка не будет жить дольше владельца (Фикс висячих указателей короче)


  • В изменяемых заимствованиях может существовать только одна mut &T. (А это фикс гонок данных)
Более низкий уровень


Что же конкретно происходит когда мы создаем переменную сложного типа данных?

fn main() {
let some_text = String::from("Привет Хабр!"); // Не путать String с &str!
}

Раз уж на то пошло давайте посмотрим что происходит в ассемблере

rustc --emit asm string_mem.rs

В сравнении с тем же С где ассемблера на вышло 23 строки в Rust получится примерно 850, я отбросил не важные нам проверки и другое, оставил только самое важное.

_ZN7habr_ex4main17h514a1b0e6cb23afdE:
.cfi_startproc
subq $24, %rsp
.cfi_def_cfa_offset 32
movq %rsp, %rdi
leaq .Lanon.ba62d6a9bc58e2b5279d52a507255ab6.11(%rip), %rsi
movl $22, %edx
callq _ZN76_$LT$alloc..string..String$u20$as$u20$core..convert..From$LT$$RF$str$GT$$GT$4from17hcbb3a0442af0ee98E
movq %rsp, %rdi
callq _ZN4core3ptr42drop_in_place$LT$alloc..string..String$GT$17h2373330cd786f1baE
addq $24, %rsp
.cfi_def_cfa_offset 8
retq

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

Вообще строка хранит ссылку на метаданные и само содержание строки в куче, это что-то вроде структуры такой формы

// Псевдокод
String {
ptr: *mut u8, // указатель на данные в куче
len: usize, // длина строки
capacity: usize // ёмкость буфера
}

  • Но вернемся к ассемблеру, название функции ZN7habrex4main17h514a1b0e6cb23afdE это зашифрованное (mangled) название. Манглирование нужно из-за допуска нескольких одинаковых названий функций с разными параметрами, также оно содержит хэш типов и путь к функции. Здесь невооруженным взглядом видно что это habr_ex::main (Название_файла::Название_функции).


  • .cfi_startproc - это директива отладки, помогает дебаггеру понимать где сохраняются регистры, как восстанавливать стек и т.п.


  • subq $24, %rsp выделяет 24 байта в стеке (уменьшая указатель стека %rsp). Это место нужно для локальной переменной, о которой надеюсь вы еще помните,some_text.


  • .cfi_de_cfa_offset 32 - опять стек, нужно для того чтобы "обновить описание" стека для отладчика, теперь CFA = %rsp + 32.


  • Следующие же 3 строки требуются для передачи аргументов вызов String::from()

    movq %rsp, %rdi
    leaq .Lanon.ba62d6a9bc58e2b5279d52a507255ab6.11(%rip), %rsi
    movl $22, %edx

    Мы используем SysV ABI amd64. Первая строка значит что-то вроде %rdi = %rsp , это мы показываем куда конкретно поместить ту структуру с метаданными и саму строку. (%rsp - Stack Pointer Register. Важно пояснить что сама строка хранится в куче, а мета структура (приводил пример выше) лежит в стеке)

    leaq .Lanon...(%rip), %rsi - загружает адрес строкового литерала "Привет Хабр!" в %rsi.

    movl $22, %edx - длина строки в байтах: 22 (в UTF-8, «Привет Хабр!» столько и занимает)


  • callq ZN76$LT$alloc.....2af0ee98E это вызов функции

    <alloc::string::String as core::convert::From<&str>>::from

    То есть уже создает сам String в куче и копирует туда данные той строки что мы указали изначально.


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

Кстати еще существует такая штука как unsafe{}. Она отключает всю безопасность и позволяет управлять памятью как тебе угодно. Сырые указатели (как в Си), некоторые FFI, обращения к union полям, инлайн ассемблер asm!().

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

❯ Как ownership уходит


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

rustc --emit mir string_mem.rs

Это похожий код просто там уходит ownership и явно указываются инструкции.

// WARNING: This output format is intended for human consumers only
// and is subject to change without notice. Knock yourself out.
// HINT: See also -Z dump-mir for MIR at specific points during compilation.
fn main() -> () {
let mut _0: ();
let _1: std::string::String;
scope 1 {
debug some_text => _1;
}

bb0: {
_1 = <String as From<&str>>::from(const "Привет Хабр!") -> [return: bb1, unwind continue];
}

bb1: {
drop(_1) -> [return: bb2, unwind continue];
}

bb2: {
return;
}
}


В ассемблере есть функции с аналогичными названиями, но эта тема еще на три статьи. А на эту статью я уже все.

Выводы


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

Также хотел бы попросить о обратной связи. Я не сильно опытен в написании статей, так что это важно. Если было интересно прошу поставить плюсик.

Лично на мой взгляд немного подкачал с форматированием ну и чуть чуть контента маловато.
 
Сверху Снизу