Привет, Хабр!
В предыдущей статье мы разобрали, как не ломать себе карьеру, бездумно используя unwrap() или игнорируя ошибки через let _ =. Но давайте честно: это были цветочки. Настоящие проблемы начинаются там, где ваш код работает «почти идеально», а потом, под грохот продакшена, вы осознаете, что все было далеко не так гладко.
Сегодня вторая часть. Разберем несколько ошибок, которые выглядят безобидно, но тащат за собой баги, утечки памяти и необъяснимые фризы.
Начнем с первой проблемы при работе с RC
и циклическими ссылками.
Ошибка с Rc и циклическими ссылками
Работая с Rc
, некоторые разработчики Rust могут попасть в одну из самых хитрых ловушек: циклические ссылки, которые создают утечки памяти. Рассмотрим следующую ситуацию:
use std::rc::Rc;
use std::cell::RefCell;
struct Node {
value: i32,
next: Option<Rc<RefCell<Node>>>,
}
fn main() {
let first = Rc::new(RefCell::new(Node { value: 1, next: None }));
let second = Rc::new(RefCell::new(Node { value: 2, next: None }));
// Связываем первый элемент со вторым
first.borrow_mut().next = Some(second.clone());
// Создаем циклическую ссылку
second.borrow_mut().next = Some(first.clone());
println!("Первый узел: {:?}", first.borrow().value);
}
На первый взгляд, код работает: мы создали связанные узлы. Однако есть серьезная проблема — утечка памяти. Rust не может автоматически обнаружить и разорвать циклические ссылки в Rc
, потому что счетчик ссылок никогда не падает до нуля. То есть память, выделенная под first
и second
, никогда не будет освобождена.
Циклические ссылки возникают, когда объекты ссылаются друг на друга через Rc
. В данном случае:
Узел
first
ссылается наsecond
.Узел
second
ссылается наfirst
.
Поскольку оба объекта имеют счетчики ссылок больше 0, они не могут быть освобождены, даже если их больше никто не использует.
Решение:
Используйте Weak
вместо Rc
для предотвращения циклических ссылок. Weak
не увеличивает счетчик ссылок и не препятствует освобождению памяти.
Исправленный код:
use std::rc::{Rc, Weak};
use std::cell::RefCell;
struct Node {
value: i32,
next: Option<Rc<RefCell<Node>>>,
prev: Option<Weak<RefCell<Node>>>, // Ссылка на предыдущий узел
}
fn main() {
let first = Rc::new(RefCell::new(Node { value: 1, next: None, prev: None }));
let second = Rc::new(RefCell::new(Node { value: 2, next: None, prev: None }));
// Связываем первый узел со вторым
first.borrow_mut().next = Some(second.clone());
second.borrow_mut().prev = Some(Rc::downgrade(&first)); // Используем Weak для обратной ссылки
println!("Первый узел: {:?}", first.borrow().value);
println!("Второй узел: {:?}", second.borrow().value);
}
Теперь second
имеет слабую ссылку на first
, а это значит, что если Rc
для first
станет равным нулю, память будет корректно освобождена.
Rust не защищает от циклических ссылок автоматически. Хотя в большинстве случаев Rc
безопасен, этот пример показывает, как легко допустить утечку памяти, если не учитывать возможные циклы. Использование Weak
— простой способ избежать этой ловушки.
tokio::spawn
Асинхронность в Rust хороша, но часто скрывает ловушку. Кто-нибудь использовал tokio::spawn
для запуска задач? А кто потом эти задачи ждал?
Если забыть про .await
или не обернуть задачу в JoinHandle
, начнутся утечки памяти и оркестрация станет хаотичной. Пример:
tokio::spawn(async {
some_async_task().await;
});
Выглядит хорошо, но задачи висят где-то в void-е и продолжают работать, даже если их уже никто не ждет.
Как исправить?
-
Всегда сохраняйте
JoinHandle
:let handle = tokio::spawn(async { some_async_task().await; }); handle.await?;
-
Если задача необязательна, логируйте результат или обрабатывайте ошибки:
tokio::spawn(async { if let Err(err) = some_async_task().await { eprintln!("Ошибка в задаче: {:?}", err); } });
Mutex в бесконечном lock
Rust имеет безопасный доступ к общим данным через Mutex
, но в неопытных руках он становится блокировочной кашей.
Ошибка:
use std::sync::Mutex;
let data = Mutex::new(vec![1, 2, 3]);
let guard = data.lock().unwrap();
let another_guard = data.lock().unwrap(); // Блокировка...
data.lock().unwrap().push(2);
println!("{:?}", data);
Здесь произойдет взаимоблокировка (именуемая deadlock), вызванная неправильным использованием Mutex
в многопоточной среде или в однопоточной программе.
Решение:
Всегда старайтесь ограничивать область жизни MutexGuard
или использовать функции:
use std::sync::Mutex;
let data = Mutex::new(vec![1, 2, 3]);
{
let guard = data.lock().unwrap();
// Работаем с guard
} // guard выходит из области видимости автоматически
Плюсом можно обратиться к паттерну RAII.
Игнорирование unsafe
unsafe
— волшебная палочка Rust, которая позволяет обойти систему типов и делать то, что обычно запрещено. Но как говорится, с великой силой приходит и большая ответственность. Когда вы используете unsafe
без должной осторожности, вы открываете дверь для множества проблем, от гонок данных до некорректной работы с указателями. Рассмотрим пример:
unsafe {
let mut num = 10;
let ptr = &mut num as *mut i32;
*ptr += 1;
println!("Число теперь: {}", *ptr);
}
На первый взгляд, все выглядит безобидно: создаем изменяемую переменную, получаем ее сырой указатель и изменяем значение. Но что, если указатель некорректен или данные изменяются из разных потоков одновременно? Это может привести к неопределенному поведению, что в Rust, в отличие от многих других языков, не остается незамеченным. Например, если указатель указывает на освобожденную память или если несколько потоков пытаются изменить одно и то же значение без синхронизации, последствия могут быть мягко говоря неприятными.
Как же минимизировать риски при использовании unsafe
?
-
Вместо того чтобы разбрасывать
unsafe
по всему коду, изолируйте его в узких, хорошо протестированных местах. Например:fn safe_add(ptr: *mut i32) { unsafe { if !ptr.is_null() { *ptr += 1; } } } fn main() { let mut num = 10; let ptr = &mut num as *mut i32; safe_add(ptr); println!("Число теперь: {}", num); }
Функция
safe_add
инкапсулируетunsafe
блок и добавляет проверку наnull
. -
Документируйте каждый
unsafe
участок:/// Увеличивает значение по указанному указателю. /// /// # Безопасность /// - Указатель `ptr` должен быть валиден и указывать на инициализированное значение. /// - Не должно быть других изменяющих ссылок на `ptr` в это время. fn safe_add(ptr: *mut i32) { unsafe { assert!(!ptr.is_null(), "Указатель не должен быть null"); *ptr += 1; } }
Многие проблемы, которые требуют
unsafe
, можно решить с помощью существующих безопасных абстракций Rust, напримерMutex
илиArc
.Покрывайте
unsafe
код тестами: напишите как можно больше тестов, чтобы убедиться, что ваши безопасные абстракции действительно защищают от возможных ошибок.
Линейная аллокация через .collect()
Ах, этот сладкий метод .collect()
, который делает все так просто. Пока вы не посмотрите на мониторинг памяти. Пример классический: превращаем итератор в вектор.
let data: Vec<_> = some_iter.map(|x| process(x)).collect();
Что тут не так? Во-первых, .collect()
жадно создает новый Vec
, выделяя память за один раз. Если итератор огромный — вы получите пик потребления памяти, сравнимый с размером всех элементов. Ну а если до этого вы клонировали данные, то готовьтесь к перерасходу памяти и драмам на продакшене. Проблема возникает не всегда, а при работе с большими объемами данных или при частых вызовах этого метода.
Как избежать?
-
Используйте методы, которые не требуют создания новой коллекции, если достаточно побочных эффектов. Например:
some_iter.for_each(|x| process(x));
-
Обратите внимание на библиотеку
rayon
для параллельных итераторов:use rayon::prelude::*; some_iter.par_iter().for_each(|x| process(x));
-
Используйте методы
filter_map
,fold
которые позволяют обрабатывать элементы без надобности их накопления в новой коллекции:let sum: i32 = some_iter.fold(0, |acc, x| acc + process(x));
Гонки данных через Rc
Вам понравился Rc
за его удобство? Но вот в многопоточной программе это все равно что использовать велосипед для гонок F1. Например:
use std::rc::Rc;
let shared_data = Rc::new(vec![1, 2, 3]);
// А теперь в потоках
std::thread::spawn(move || {
let _ = shared_data.clone();
});
Программа просто не компилируется. Почему? Потому что Rc
не потокобезопасен. И даже если бы компилировалась (скажем, через unsafe
), данные бы развалились.
Вместо этого можно использовать Arc
use std::sync::Arc;
let shared_data = Arc::new(vec![1, 2, 3]);
Также можно добавлять мьютексы или атомарные операции для контроля доступа.
Отсутствие тестов на крайние случаи
Тесты в Rust обычно просты, пока вы не начинаете их игнорировать. Например, ваш код идеально обрабатывает 99% запросов, но ломается на пустых или слишком больших значениях.
fn process(data: &[i32]) -> i32 {
data.iter().sum()
}
Что произойдет, если data
— пустой массив? Да, это сработает. А если в массиве миллиард элементов? Поздравляю, вы превысили лимит i32
.
Поэтому пишите тесты для граничных случаев:
#[test]
fn test_process_empty() {
assert_eq!(process(&[]), 0);
}
#[test]
fn test_process_large() {
let data = vec![1; 1_000_000_000];
assert!(process(&data) > 0);
}
Чрезмерная аллокация памяти
Да, Rust дает мощный контроль над памятью, но иногда можно перестараться. Чрезмерное создание объектов в куче приводит к увеличению времени работы программы.
Код, который тихо убивает производительность:
let mut big_data = Vec::new();
for i in 0..1_000_000 {
big_data.push(Box::new(i));
}
Каждый Box::new(i)
создает объект в куче, а это медленно.
Решение:
Используйте
Vec
илиArray
вместоBox
, если возможно.Предварительно выделяйте память для коллекций с помощью
with_capacity()
:
let mut big_data = Vec::with_capacity(1_000_000)
Переусложненные замыкания
Красота, лаконичность, функциональный стиль — все это, пока вы не начнете перегружать их излишними вычислениями.
let heavy_closure = |x: i32| {
let result = (1..1_000_000)
.filter(|&n| n % x == 0)
.map(|n| n * 2)
.collect::<Vec<_>>();
result.len()
};
На первый взгляд, ничего особенного — всё выглядит компактно. Но по сути это не просто замыкание, а полноценный кусок бизнес-логики, внесенный внутрь одного блока. Такой код трудно поддерживать: при необходимости изменений или добавления новых шагов внутри замыкания читаемость сильно упадет.
Замыкания должны быть компактными и сфокусированными. Основная цель — передача логики без излишних деталей. Пример злоупотребления:
let messy_closure = |data: Vec<i32>, multiplier: i32| {
let filtered = data
.into_iter()
.filter(|&n| n % 2 == 0)
.map(|n| n * multiplier)
.collect::<Vec<_>>();
let sum = filtered.iter().sum::<i32>();
let count = filtered.len();
sum as f64 / count as f64
};
Это замыкание:
фильтрует данные,
умножает элементы на коэффициент,
подсчитывает сумму,
вычисляет среднее значение.
Логика сложная, и поддерживать ее тяжело. Если потребуется изменить только одно из вычислений (например, добавить логику подсчета медианы), придется разбираться с перегруженным блоком.
Решение: вынести сложные вычисления в отдельные функции. Замыкания хороши для лаконичных операций, но тяжелые задачи стоит разделить на части:
fn process_data(data: Vec<i32>, multiplier: i32) -> Vec<i32> {
data.into_iter()
.filter(|&n| n % 2 == 0)
.map(|n| n * multiplier)
.collect()
}
fn calculate_average(data: &[i32]) -> f64 {
let sum: i32 = data.iter().sum();
let count = data.len();
sum as f64 / count as f64
}
let clean_closure = |data: Vec<i32>, multiplier: i32| {
let processed = process_data(data, multiplier);
calculate_average(&processed)
};
Теперь каждый кусок логики изолирован и понятен, а так же можно переиспользовать функции process_data и calculate_average в других частях программы.
Компилятор скажет спасибо, а вы — себе.
Заключение
В Rust не бывает скучно. Но каждый баг — это не только грабли, но и урок. Пишите чисто, профилируйте код, используйте Clippy, и самое главное — не забывайте делиться своими ошибками. Потому что чужие грабли — лучший учитель.
А поделиться своими ошибками вы можете в комментариях к этой статье. Если будут интересные кейсы — разберем их в следующей статье.
Комментарии (7)
Siemargl
09.12.2024 21:03Непонятно, откуда в Расте появился сборщик мусора? Скорее уж это фрагментация хипа.
А так все перечисленные проблемы от языка не зависят.
ReadOnlySadUser
09.12.2024 21:03Прочитал статью. Проблемы понятные и распространеннные, новичкам не лишним будет прочитать, но причём тут Rust? Всё эти проблемы существуют в любом языке, в котором есть ручное управление памятью, указатели и корутины.
Что забавно, проблема с тем, что Rc не потокобезопасен - так и вообще не проблема именно в Rust. Ведь код не скомпилируется! А значит и не запустится, а значит и не сломается.
boldape
09.12.2024 21:03Последний прием про лямбду вообще не является проблемой, а лишь явное субъективное выражение одобрения одного стиля и осуждение другого.
Мне такие лямбды читаются легко, редактируются легко, но я бы предпочел обычный цикл если код получится короче/проще. Однако я бы ни за что не стал бы переписывать на предложенный вариант, т.к. размазывание бизнес логики на несколько методов ухудшает чтение и понимание во много раз больше чем любой синтаксис лямбд.
Простые независимые от бизнес логики методы уже есть : фильтр, сум, уверен есть в каком нибудь крэйте и прочие авередж медиан и что вам только не надо. Так вот именно бизнес логику НЕ надо переиспользовать. Просто нет одинаковой бизнес логики, даже каких-то частей, она уникальна и разбивка на отдельные методы/блоки/классы че угодно делает только хуже.
Опять же это всего лишь мое мнение/философия/стиль. Разбитый на функции код работает, а значит обьективных проблем с ним нет, но имхо лямбда лучше.
Siemargl
09.12.2024 21:03И тут в бизнес логике появляются изменения, как обычно многоэтажные зависимости от фаз луны итп, и лямбда перестаёт быть красивой как в учебнике. Приходится переписывать все=)
lesha108
Отсутствие документации на макросы в библиотеках - вот где беда (