Rust без прикрас: где мы продолжаем ошибаться

от автора

Привет, Хабр!

В предыдущей статье мы разобрали, как не ломать себе карьеру, бездумно используя 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. В данном случае:

  1. Узел first ссылается на second.

  2. Узел 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-е и продолжают работать, даже если их уже никто не ждет.

Как исправить?

  1. Всегда сохраняйте JoinHandle:

    let handle = tokio::spawn(async {     some_async_task().await; }); handle.await?;
  2. Если задача необязательна, логируйте результат или обрабатывайте ошибки:

    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?

  1. Вместо того чтобы разбрасывать 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.

  2. Документируйте каждый unsafe участок:

    /// Увеличивает значение по указанному указателю. /// /// # Безопасность /// - Указатель `ptr` должен быть валиден и указывать на инициализированное значение. /// - Не должно быть других изменяющих ссылок на `ptr` в это время. fn safe_add(ptr: *mut i32) {     unsafe {         assert!(!ptr.is_null(), "Указатель не должен быть null");         *ptr += 1;     } }
  3. Многие проблемы, которые требуют unsafe, можно решить с помощью существующих безопасных абстракций Rust, напримерMutexили Arc.

  4. Покрывайте 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/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *