Начну с признания, которое может вызвать недовольство в сообществе Rust: Rust не предотвращает утечки памяти. Вот, я это сказал. И прежде чем возьмутся за вилы, позвольте уточнить: это не баг, это особенность. Вернее, это продуманное дизайнерское решение, которое раскрывает нечто увлекательное о том, что на самом деле означает «безопасность памяти».
Видите ли, когда мы, евангелисты, говорим, что Rust «безопасен в плане памяти», мы рисуем довольно широкими мазками. Нам нравится противопоставлять его C и C++, где висячий указатель может вызвать демонов через ваше нос (неопределённое поведение, для непосвящённых). Но вот неудобная правда: гарантии безопасности памяти в Rust более нюансированы, чем предполагают маркетинговые материалы. Утечки памяти не только возможны в Rust — они явно считаются безопасными в плане памяти.
Если это звучит парадоксально, приготовьтесь. Мы собираемся углубиться в один из самых неправильно понимаемых аспектов Rust, и я обещаю, что это изменит ваше представление о гарантиях безопасности в системном программировании.
Что на самом деле обещает Rust (и чего не обещает)
Давайте разберёмся с определениями, прежде чем идти дальше. Когда Rust утверждает, что он «безопасен в плане памяти», он даёт определённые обещания о том, какие ошибки он предотвращает на этапе компиляции:
Rust предотвращает:
- Ошибки использования после освобождения памяти (use-after-free).
- Двойное освобождение памяти (double frees).
- Висячие указатели (dangling pointers).
- Гонки данных в параллельном коде (data races in concurrent code).
- Переполнение буфера (в безопасном коде).
- Обращение к нулевому указателю (null pointer dereferences).
Rust не предотвращает:
- Утечки памяти.
- Логические ошибки.
- Взаимоблокировки (deadlocks).
- Исчерпание памяти.
Заметьте что-нибудь интересное? Утечек памяти нет в списке «предотвращаемых». Это не упущение — это философия. Команда Rust решила на раннем этапе, что предотвращение утечек памяти полностью будет несовместимо с целями языка по абстракции без затрат. И, честно говоря, они могут быть правы, даже если это усложняет наши маркетинговые презентации.
Ключевое понимание здесь заключается в том, что утечки памяти не нарушают безопасность памяти в техническом смысле. Утечка означает, что память остаётся неиспользованной, но выделенной — это не означает, что вы читаете неверные значения или записываете в освобождённую память. Ваша программа неверна, конечно, но она неверна предсказуемым, содержательным образом.
Две стороны утечки памяти
Rust предоставляет два основных пути для утечки памяти, каждый из которых раскрывает различные аспекты философии дизайна языка. Давайте рассмотрим оба на примерах работающего кода, которые вы можете запустить сами.
Дилемма циклических ссылок
Первый и наиболее обсуждаемый способ утечки памяти в Rust связан с циклами ссылок, используя Rc<T>
и RefCell<T>
. Здесь всё становится интересным с теоретической точки зрения.
Система владения в Rust блестяща для ациклических структур данных. Но как только вам нужен граф или двусвязный список, вы упираетесь в стену. Компилятор не может определить, кто кому принадлежит, когда A указывает на B, а B указывает на A. Вводятся Rc<T>
(подсчёт ссылок) и RefCell<T>
(внутренняя изменяемость) — инструменты, которые позволяют обойти правила владения во время выполнения.
Вот минимальный пример, который приводит к утечке памяти:
use std::cell::RefCell;
use std::rc::Rc;
#[derive(Debug)]
struct Node {
value: i32,
next: Option<Rc<RefCell<Node>>>,
}
fn create_cycle() {
// Создаём два узла
let node_a = Rc::new(RefCell::new(Node {
value: 1,
next: None,
}));
let node_b = Rc::new(RefCell::new(Node {
value: 2,
next: None,
}));
// Создаём цикл: A -> B -> A
node_a.borrow_mut().next = Some(Rc::clone(&node_b));
node_b.borrow_mut().next = Some(Rc::clone(&node_a));
println!("Счётчик ссылок узла node_a: {}", Rc::strong_count(&node_a));
println!("Счётчик ссылок узла node_b: {}", Rc::strong_count(&node_b));
// Когда эта функция завершится, оба узла будут иметь счётчик ссылок 2
// Они ссылаются друг на друга, поэтому ни один из них не может быть удалён
// Утечка памяти достигнута!
}
fn main() {
create_cycle();
println!("Функция вернула
ся, но память всё ещё утекает!");
}
Запустите этот код, и вы увидите, что оба узла имеют счётчик ссылок 2, когда функция завершается. Когда node_a
и node_b
выходят из области видимости, их счётчики ссылок уменьшаются до 1, но никогда не достигают 0, что означает, что их деструкторы никогда не запускаются, и память никогда не освобождается.
Вот что происходит под капотом:
Исправление? Используйте ссылки Weak<T>
, чтобы разорвать цикл:
use std::cell::RefCell;
use std::rc::{Rc, Weak};
#[derive(Debug)]
struct Node {
value: i32,
next: Option<Rc<RefCell<Node>>>,
prev: Option<Weak<RefCell<Node>>>, // Слабая ссылка!
}
fn create_no_cycle() {
let node_a = Rc::new(RefCell::new(Node {
value: 1,
next: None,
prev: None,
}));
let node_b = Rc::new(RefCell::new(Node {
value: 2,
next: None,
prev: None,
}));
// A -> B (сильная), B -> A (слабая)
node_a.borrow_mut().next = Some(Rc::clone(&node_b));
node_b.borrow_mut().prev = Some(Rc::downgrade(&node_a));
println!("Сильный счётчик узла node_a: {}", Rc::strong_count(&node_a));
println!("Слабый счётчик узла node_a: {}", Rc::weak_count(&node_a));
// Утечки нет! Слабые ссылки не препятствуют освобождению памяти
}
Неограниченный аппетит растущих коллекций
Второй способ утечки памяти настолько очевиден, что почти смущает: просто продолжайте добавлять элементы в Vec
и никогда не останавливайтесь. Вот канонический пример:
fn leak_with_vec() {
let mut data: Vec<i32> = Vec::new();
loop {
data.push(42);
if data.len() % 1_000_000 == 0 {
println!("Утечка {} МБ", data.len() * 4 / 1_000_000);
}
}
}
Это утечка памяти в самом прямом смысле — мы непрерывно выделяем память, которую никогда не освобождаем. Но вот загвоздка: автоматически обнаружить это невозможно. Нет, серьёзно. Это проблема остановки в новом обличии.
Чтобы обнаружить эту утечку, компилятору нужно было бы определить, прекращается ли вызов data.push(42)
. Но определение того, останавливается ли произвольная программа, неразрешимо — один из самых известных результатов невозможности в информатике. Так что, если ваш цикл не тривиально очевиден (как тот, что выше), ни один статический анализатор не сможет его поймать.
Вот более реалистичный пример, который сложнее обнаружить:
use std::collections::HashMap;
struct LeakyCache {
cache: HashMap<String, Vec<u8>>,
}
impl LeakyCache {
fn new() -> Self {
LeakyCache {
cache: HashMap::new(),
}
}
fn cache_data(&mut self, key: String, data: Vec<u8>) {
// Упс! Мы никогда не удаляем старые записи
// Это будет расти неограниченно, если ключи уникальны
self.cache.insert(key, data);
}
fn get_data(&self, key: &str) -> Option<&Vec<u8>> {
self.cache.get(key