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

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

А поделиться своими ошибками вы можете в комментариях к этой статье. Если будут интересные кейсы — разберем их в следующей статье.

Комментарии (7)


  1. lesha108
    09.12.2024 21:03

    Отсутствие документации на макросы в библиотеках - вот где беда (


  1. Siemargl
    09.12.2024 21:03

    Непонятно, откуда в Расте появился сборщик мусора? Скорее уж это фрагментация хипа.

    А так все перечисленные проблемы от языка не зависят.


    1. domix32
      09.12.2024 21:03

      Непонятно, откуда в Расте появился сборщик мусора?

      Так про него вроде и не упоминалось


      1. Siemargl
        09.12.2024 21:03

        Было в части Чрезмерная аллокация памяти. Уже автор убрал упоминание про него. Правда, суть этой части тоже пропала..


  1. ReadOnlySadUser
    09.12.2024 21:03

    Прочитал статью. Проблемы понятные и распространеннные, новичкам не лишним будет прочитать, но причём тут Rust? Всё эти проблемы существуют в любом языке, в котором есть ручное управление памятью, указатели и корутины.

    Что забавно, проблема с тем, что Rc не потокобезопасен - так и вообще не проблема именно в Rust. Ведь код не скомпилируется! А значит и не запустится, а значит и не сломается.


  1. boldape
    09.12.2024 21:03

    Последний прием про лямбду вообще не является проблемой, а лишь явное субъективное выражение одобрения одного стиля и осуждение другого.

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

    Простые независимые от бизнес логики методы уже есть : фильтр, сум, уверен есть в каком нибудь крэйте и прочие авередж медиан и что вам только не надо. Так вот именно бизнес логику НЕ надо переиспользовать. Просто нет одинаковой бизнес логики, даже каких-то частей, она уникальна и разбивка на отдельные методы/блоки/классы че угодно делает только хуже.

    Опять же это всего лишь мое мнение/философия/стиль. Разбитый на функции код работает, а значит обьективных проблем с ним нет, но имхо лямбда лучше.


    1. Siemargl
      09.12.2024 21:03

      И тут в бизнес логике появляются изменения, как обычно многоэтажные зависимости от фаз луны итп, и лямбда перестаёт быть красивой как в учебнике. Приходится переписывать все=)