Привет, Хабр!
В предыдущей статье мы разобрали, как не ломать себе карьеру, бездумно используя 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, и самое главное — не забывайте делиться своими ошибками. Потому что чужие грабли — лучший учитель.
А поделиться своими ошибками вы можете в комментариях к этой статье. Если будут интересные кейсы — разберем их в следующей статье.
ссылка на оригинал статьи https://habr.com/ru/articles/861496/
Добавить комментарий