Что общего между документацией Rust и советами бабушки? И то, и другое звучит разумно, пока не начнёшь применять буквально ко всему. «Используй дженерики для переиспользования кода», «оборачивай общие данные в Arc<Mutex>», «создавай типизированные ошибки» — всё это написано в книгах, статьях и туториалах. И всё это может превратить ваш проект в нечто, от чего хочется плакать.

Мономорфизация

Начну с того, что мономорфизация в Rust — это действительно круто. Компилятор берёт вашу обобщённую функцию и генерирует специализированные версии для каждого конкретного типа. Никаких накладных расходов во время выполнения, всё инлайнится, оптимизатор счастлив.

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

pub struct Repository<T, S, C, L>
where
    T: Transport + Clone + Send + Sync + 'static,
    S: Serializer + Default,
    C: Cache<Key = String, Value = Vec<u8>>,
    L: Logger + Clone,
{
    transport: T,
    serializer: S,
    cache: C,
    logger: L,
}

impl<T, S, C, L> Repository<T, S, C, L>
where
    T: Transport + Clone + Send + Sync + 'static,
    S: Serializer + Default,
    C: Cache<Key = String, Value = Vec<u8>>,
    L: Logger + Clone,
{
    pub fn new(transport: T, cache: C, logger: L) -> Self {
        Self {
            transport,
            serializer: S::default(),
            cache,
            logger,
        }
    }

    pub fn fetch<K, V>(&self, key: K) -> Result<V, RepositoryError>
    where
        K: AsRef<str> + Hash + Eq,
        V: DeserializeOwned + Serialize,
    {
        // реализация
        todo!()
    }
}

Выглядит солидно. Четыре типовых параметра на структуре, ещё два на методе. Теперь представьте, что у вас десять таких структур, и они используют друг друга. Каждая комбинация типов порождает отдельную версию кода.

На практике это означает следующее. Во‑первых, время компиляции растёт экспоненциально. Во‑вторых, размер бинарника раздувается, потому что одна и та же логика дублируется для каждой комбинации типов. В‑третьих, инкрементальная компиляция страдает: поменял один трейт, и половина проекта ушла на пересборку.

Что делать вместо этого? Использовать trait objects там, где производительность не критична. Да, будет косвенный вызов через vtable. Да, это наносекунды. Но если код делает сетевой запрос или читает с диска, эти наносекунды не имеют никакого значения.

pub struct Repository {
    transport: Box<dyn Transport + Send + Sync>,
    serializer: Box<dyn Serializer + Send + Sync>,
    cache: Box<dyn Cache<Key = String, Value = Vec<u8>> + Send + Sync>,
    logger: Box<dyn Logger + Send + Sync>,
}

impl Repository {
    pub fn new(
        transport: impl Transport + Send + Sync + 'static,
        serializer: impl Serializer + Send + Sync + 'static,
        cache: impl Cache<Key = String, Value = Vec<u8>> + Send + Sync + 'static,
        logger: impl Logger + Send + Sync + 'static,
    ) -> Self {
        Self {
            transport: Box::new(transport),
            serializer: Box::new(serializer),
            cache: Box::new(cache),
            logger: Box::new(logger),
        }
    }
}

Конструктор всё ещё принимает конкретные типы через impl Trait, так что вызывающий код не меняется. Но внутри структуры хранятся trait objects, и компилятору не нужно генерировать отдельную версию Repository для каждой комбинации.

Есть ещё один приём, который часто упускают из виду. Можно вынести общую логику в немономорфизируемые внутренние функции:

impl<T: AsRef<[u8]>> Hasher<T> {
    pub fn hash(&self, data: T) -> [u8; 32] {
        // Эта функция не зависит от T
        hash_impl(data.as_ref())
    }
}

// Одна версия для всех типов
fn hash_impl(data: &[u8]) -> [u8; 32] {
    use sha2::{Sha256, Digest};
    let mut hasher = Sha256::new();
    hasher.update(data);
    hasher.finalize().into()
}

Публичный API остаётся удобным и обобщённым, но основная работа происходит в функции, которая компилируется один раз.

Итак, дженерики оправданы для горячих путей, коллекций и математических абстракций. Для всего остального, особенно для инфраструктурного кода с внешними зависимостями, trait objects работают не хуже, а компилируются значительно быстрее.

Arc везде

Arc<Mutex<T>> — это, наверное, самый популярный способ шарить данные между потоками в Rust.

Проблема Arc<Mutex> не в том, что он плохой. Проблема в том, что он создаёт иллюзию, будто многопоточность — это просто. Обернул данные в Arc<Mutex>, клонировал ссылку, захватил лок — и готово. На практике всё сложнее.

Рассмотрим сценарий:

use std::sync::{Arc, Mutex};
use std::collections::HashMap;

struct UserCache {
    users: Arc<Mutex<HashMap<u64, User>>>,
    sessions: Arc<Mutex<HashMap<String, u64>>>,
}

impl UserCache {
    fn get_user_by_session(&self, session_id: &str) -> Option<User> {
        // Захватываем первый лок
        let sessions = self.sessions.lock().unwrap();
        let user_id = sessions.get(session_id)?;
        
        // Захватываем второй лок, не отпуская первый
        let users = self.users.lock().unwrap();
        users.get(user_id).cloned()
    }
    
    fn update_user_session(&self, user_id: u64, session_id: String) {
        // Тот же порядок? Или другой?
        let mut users = self.users.lock().unwrap();
        // Проверяем, что пользователь существует
        if users.contains_key(&user_id) {
            let mut sessions = self.sessions.lock().unwrap();
            sessions.insert(session_id, user_id);
        }
    }
    
    fn cleanup_expired(&self) {
        // А здесь порядок точно другой
        let mut sessions = self.sessions.lock().unwrap();
        let expired: Vec<_> = sessions
            .iter()
            .filter(|(_, uid)| self.is_user_expired(**uid))
            .map(|(sid, _)| sid.clone())
            .collect();
        
        for sid in expired {
            sessions.remove(&sid);
        }
    }
    
    fn is_user_expired(&self, user_id: u64) -> bool {
        // Упс, захватываем users внутри, пока держим sessions
        let users = self.users.lock().unwrap();
        users.get(&user_id).map(|u| u.expired).unwrap_or(true)
    }
}

В get_user_by_session мы сначала захватываем sessions, потом users. В update_user_session — сначала users, потом sessions. В cleanup_expired вызываем is_user_expired, которая захватывает users, пока мы держим sessions. Это такая база дедлока.

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

Что использовать вместо Arc<Mutex>? Зависит от паттерна доступа.

Если у вас много читателей и редкие записи, RwLock будет значительно эффективнее:

use std::sync::RwLock;
use std::collections::HashMap;

struct UserCache {
    // Одна структура - один лок, никаких проблем с порядком
    data: RwLock<CacheData>,
}

struct CacheData {
    users: HashMap<u64, User>,
    sessions: HashMap<String, u64>,
}

impl UserCache {
    fn get_user_by_session(&self, session_id: &str) -> Option<User> {
        let data = self.data.read().unwrap();
        let user_id = data.sessions.get(session_id)?;
        data.users.get(user_id).cloned()
    }
    
    fn update_user_session(&self, user_id: u64, session_id: String) {
        let mut data = self.data.write().unwrap();
        if data.users.contains_key(&user_id) {
            data.sessions.insert(session_id, user_id);
        }
    }
}

Объединив связанные данные под одним локом, мы устранили возможность дедлока. Да, теперь чтение сессий блокирует чтение пользователей. Но на практике это редко проблема, а отсутствие дедлоков — это гарантия.

Для счётчиков и флагов используйте атомарные типы. Они не требуют локов вообще:

use std::sync::atomic::{AtomicU64, AtomicBool, Ordering};

struct Metrics {
    requests_total: AtomicU64,
    is_healthy: AtomicBool,
}

impl Metrics {
    fn record_request(&self) {
        self.requests_total.fetch_add(1, Ordering::Relaxed);
    }
    
    fn set_unhealthy(&self) {
        self.is_healthy.store(false, Ordering::Release);
    }
    
    fn check_health(&self) -> bool {
        self.is_healthy.load(Ordering::Acquire)
    }
}

Ordering — это отдельная большая тема, но для простых счётчиков Relaxed достаточно, а для флагов, которые синхронизируют доступ к другим данным, используйте Release при записи и Acquire при чтении.

Для передачи данных между потоками каналы часто работают лучше, чем общая память:

use std::sync::mpsc;
use std::thread;

enum CacheCommand {
    Get { session_id: String, reply: mpsc::Sender<Option<User>> },
    Update { user_id: u64, session_id: String },
    Cleanup,
}

fn cache_actor(rx: mpsc::Receiver<CacheCommand>) {
    let mut users: HashMap<u64, User> = HashMap::new();
    let mut sessions: HashMap<String, u64> = HashMap::new();
    
    while let Ok(cmd) = rx.recv() {
        match cmd {
            CacheCommand::Get { session_id, reply } => {
                let user = sessions.get(&session_id)
                    .and_then(|uid| users.get(uid))
                    .cloned();
                let _ = reply.send(user);
            }
            CacheCommand::Update { user_id, session_id } => {
                if users.contains_key(&user_id) {
                    sessions.insert(session_id, user_id);
                }
            }
            CacheCommand::Cleanup => {
                sessions.retain(|_, uid| {
                    users.get(uid).map(|u| !u.expired).unwrap_or(false)
                });
            }
        }
    }
}

Паттерн «актор» выглядит более громоздким, но он принципиально исключает гонки данных. Вся мутация происходит в одном потоке, остальные только отправляют сообщения.

Для высоконагруженных сценариев, где локи становятся узким местом, существуют lock‑free структуры данных. Крейт crossbeam имеет отличные реализации:

use crossbeam::queue::SegQueue;
use crossbeam::epoch::{self, Atomic, Owned};

// Очередь без локов
let queue: SegQueue<Task> = SegQueue::new();

// Из любого потока
queue.push(Task::new());

// Из любого потока
if let Some(task) = queue.pop() {
    process(task);
}

Прежде чем оборачивать данные в Arc<Mutex>, подумайте: может быть, RwLock подойдёт лучше? Может быть, достаточно атомарного типа? Может быть, данные вообще не нужно шарить, а достаточно передавать сообщения?

Своя ошибка на каждый чих

Rust заставляет обрабатывать ошибки явно. Это хорошо. thiserror делает создание типизированных ошибок простым. Это тоже хорошо. А потом кто‑то решает, что раз создавать ошибки легко, то нужно создать отдельный тип для каждого модуля, функции и, желательно, для каждой строчки кода.

Я видел проект, где было больше пятидесяти типов ошибок. И у каждого был свой enum с десятком вариантов. Чтобы добавить новую фичу, нужно было создать новый тип ошибки, добавить его конвертацию во все вышестоящие типы, обновить матчинг в десяти местах.

Как это выглядит в миниатюре:

use thiserror::Error;

#[derive(Error, Debug)]
pub enum ConfigError {
    #[error("failed to read config file: {0}")]
    ReadError(#[from] std::io::Error),
    #[error("failed to parse config: {0}")]
    ParseError(#[from] toml::de::Error),
    #[error("missing required field: {0}")]
    MissingField(String),
}

#[derive(Error, Debug)]
pub enum DatabaseError {
    #[error("connection failed: {0}")]
    ConnectionError(#[from] sqlx::Error),
    #[error("query failed: {0}")]
    QueryError(String),
    #[error("record not found")]
    NotFound,
}

#[derive(Error, Debug)]
pub enum CacheError {
    #[error("redis error: {0}")]
    RedisError(#[from] redis::RedisError),
    #[error("serialization error: {0}")]
    SerializationError(#[from] serde_json::Error),
    #[error("cache miss")]
    CacheMiss,
}

#[derive(Error, Debug)]
pub enum ServiceError {
    #[error("config error: {0}")]
    Config(#[from] ConfigError),
    #[error("database error: {0}")]
    Database(#[from] DatabaseError),
    #[error("cache error: {0}")]
    Cache(#[from] CacheError),
    #[error("validation error: {0}")]
    Validation(String),
}

#[derive(Error, Debug)]
pub enum ApiError {
    #[error("service error: {0}")]
    Service(#[from] ServiceError),
    #[error("authentication failed")]
    Unauthorized,
    #[error("rate limit exceeded")]
    RateLimited,
}

Пять уровней вложенности ошибок. Каждый уровень добавляет свою обёртку. Чтобы понять, что реально пошло не так, нужно размотать всю цепочку.

Главный вопрос, который стоит задать: кому нужна эта типизация? Если вы просто логируете ошибку и возвращаете 500 клиенту, вам не нужны пятьдесят разных типов. Вам нужен текст ошибки и, возможно, backtrace.

Для большинства приложений достаточно anyhow:

use anyhow::{Context, Result, bail};

fn load_config(path: &str) -> Result<Config> {
    let content = std::fs::read_to_string(path)
        .with_context(|| format!("failed to read config from {}", path))?;
    
    let config: Config = toml::from_str(&content)
        .context("failed to parse config")?;
    
    if config.database_url.is_empty() {
        bail!("database_url is required");
    }
    
    Ok(config)
}

async fn get_user(db: &Pool, user_id: i64) -> Result<User> {
    sqlx::query_as!(User, "SELECT * FROM users WHERE id = $1", user_id)
        .fetch_optional(db)
        .await
        .context("database query failed")?
        .ok_or_else(|| anyhow::anyhow!("user {} not found", user_id))
}

Код стал проще, контекст ошибки понятен, и при этом мы не потеряли информацию. anyhow сохраняет всю цепочку причин, и её можно вывести при необходимости.

Типизированные ошибки нужны в двух случаях. Первый — когда вызывающий код должен по‑разному реагировать на разные ошибки:

#[derive(Error, Debug)]
pub enum AuthError {
    #[error("invalid credentials")]
    InvalidCredentials,
    #[error("account locked until {0}")]
    AccountLocked(DateTime<Utc>),
    #[error("token expired")]
    TokenExpired,
}

// Вызывающий код действительно делает разные вещи
match authenticate(credentials).await {
    Ok(token) => Ok(token),
    Err(AuthError::InvalidCredentials) => {
        increment_failed_attempts(user_id).await;
        Err(ApiError::unauthorized())
    }
    Err(AuthError::AccountLocked(until)) => {
        Err(ApiError::locked(until))
    }
    Err(AuthError::TokenExpired) => {
        // Пробуем обновить токен
        refresh_token(credentials).await
    }
}

Второй случай — публичный API библиотеки, где пользователям нужно уметь обрабатывать ошибки программно.

Для всего остального anyhow с контекстом работает прям отлично.

Тотальная паника

Все мы писали unwrap в прототипах. Это быстро, это просто и оно компилируется.

Бывает код, где unwrap и expect использовался как основной способ обработки ошибок. Аргументация обычно такая: «это никогда не должно случиться», «если это произойдёт, всё равно ничего не сделаешь», «проще перезапустить сервис».

Пример:

fn process_webhook(payload: &str) -> WebhookResult {
    let data: WebhookData = serde_json::from_str(payload).unwrap();
    let user_id = data.user_id.parse::<i64>().unwrap();
    let user = GLOBAL_CACHE.get(&user_id).unwrap();
    let result = user.process(data.action).unwrap();
    
    WebhookResult { success: true, data: result }
}

Четыре unwrap в пяти строках. Каждый из них — потенциальная паника, которая уронит весь воркер. А вебхуки, как известно, приходят с внешних систем и могут содержать что угодно.

Паники в Rust не предназначены для обработки ожидаемых ошибок. Паника — это сигнал о баге в программе, о нарушении инвариантов, о ситуации, которая не должна была произойти никогда. Когда внешний сервис присылает невалидный JSON — это не баг в вашей программе, это нормальная ситуация, которую нужно обработать.

Проблемы с паниками выходят за рамки падения одного запроса. Во‑первых, если вы используете многопоточность, паника в одном потоке может оставить систему в некорректном состоянии. Мьютексы становятся отравленными, данные могут быть частично обновлены. Во‑вторых, в асинхронном коде паника в задаче может привести к утечке ресурсов или повисшим соединениям. В‑третьих, паники сложно отлаживать, вы получаете backtrace, но теряете контекст ошибки.

Как переписать код без паник:

fn process_webhook(payload: &str) -> Result<WebhookResult, WebhookError> {
    let data: WebhookData = serde_json::from_str(payload)
        .map_err(|e| WebhookError::InvalidPayload(e.to_string()))?;
    
    let user_id: i64 = data.user_id.parse()
        .map_err(|_| WebhookError::InvalidUserId(data.user_id.clone()))?;
    
    let user = GLOBAL_CACHE.get(&user_id)
        .ok_or(WebhookError::UserNotFound(user_id))?;
    
    let result = user.process(data.action)
        .map_err(WebhookError::ProcessingFailed)?;
    
    Ok(WebhookResult { success: true, data: result })
}

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

Есть места, где expect оправдан. Это инициализация программы, где ошибка фатальна:

fn main() {
    let config = Config::load()
        .expect("Failed to load configuration");
    
    let db_pool = create_pool(&config.database_url)
        .expect("Failed to connect to database");
    
    // Дальше паниковать не нужно
    if let Err(e) = run_server(config, db_pool) {
        eprintln!("Server error: {}", e);
        std::process::exit(1);
    }
}

Также expect нормален в тестах и для утверждений, которые проверяют логическую корректность кода:

fn get_first_char(s: &str) -> char {
    // Мы проверили, что строка не пустая
    assert!(!s.is_empty(), "string must not be empty");
    s.chars().next().expect("we just checked the string is not empty")
}

Но даже здесь стоит подумать, не лучше ли вернуть Option или Result.

Если ошибка может произойти из‑за внешних данных, сетевых проблем или действий пользователя, она должна обрабатываться через Result. Паника — только для багов и нарушений инвариантов.

Async везде

С асинхронным Rust можно обрабатывать тысячи соединений в одном потоке, не блокируя. Но где‑то по пути мы все решили, что раз async доступен, его нужно использовать везде.

В результате появляется код вроде такого:

async fn validate_email(email: &str) -> bool {
    // Никакого IO, чистая CPU работа
    email.contains('@') && email.contains('.')
}

async fn calculate_hash(data: &[u8]) -> [u8; 32] {
    // Тоже чистый CPU
    use sha2::{Sha256, Digest};
    let mut hasher = Sha256::new();
    hasher.update(data);
    hasher.finalize().into()
}

async fn process_item(item: Item) -> ProcessedItem {
    let email_valid = validate_email(&item.email).await;
    let hash = calculate_hash(&item.data).await;
    ProcessedItem { email_valid, hash, ..item }
}

Каждая async функция создаёт машину состояний. Каждый await — это потенциальная точка приостановки и возобновления. Для функций, которые не делают никакого IO, это такой вот чистый оверхед.

Хуже того, если функция async, все её вызывающие тоже должны быть async или использовать block_on. Это приводит к ситуациям, когда половина кодовой базы async только потому, что где‑то глубоко внутри есть один сетевой вызов.

Ещё одна проблема — блокирующие операции в async‑контексте:

async fn load_and_process(path: &str) -> Result<Data, Error> {
    // ПЛОХО: std::fs блокирует поток исполнителя
    let content = std::fs::read_to_string(path)?;
    
    // ПЛОХО: тяжёлые вычисления блокируют исполнителя
    let processed = heavy_computation(&content);
    
    // Только это реально асинхронное
    send_to_server(&processed).await?;
    
    Ok(processed)
}

Когда вы вызываете блокирующую функцию внутри async‑задачи, вы блокируете поток исполнителя. Если у вас tokio с настройками по дефолту, это один из немногих потоков, которые обрабатывают все ваши async‑задачи. Один заблокированный поток — и пропускная способность падает.

Как делать правильно:

// Синхронные функции для синхронной работы
fn validate_email(email: &str) -> bool {
    email.contains('@') && email.contains('.')
}

fn calculate_hash(data: &[u8]) -> [u8; 32] {
    use sha2::{Sha256, Digest};
    let mut hasher = Sha256::new();
    hasher.update(data);
    hasher.finalize().into()
}

// Async только для IO
async fn load_and_process(path: &str) -> Result<Data, Error> {
    // Асинхронное чтение файла
    let content = tokio::fs::read_to_string(path).await?;
    
    // Тяжёлые вычисления выносим в отдельный поток
    let processed = tokio::task::spawn_blocking(move || {
        heavy_computation(&content)
    }).await?;
    
    send_to_server(&processed).await?;
    
    Ok(processed)
}

spawn_blocking выполняет closure в отдельном пуле потоков, предназначенном для блокирующих операций. Основные потоки исполнителя остаются свободными для обработки async‑задач.

Правило для async: используйте его для IO‑bound операций. Для CPU‑bound работы используйте обычные потоки или spawn_blocking. Для простых синхронных функций async не нужен вообще.


Все описанные паттерны объединяет одно: они выглядят правильно в изоляции.

Всегда задавайте вопросы перед применением паттерна. Нужна ли здесь мономорфзация, или trait object достаточно? Нужен ли Mutex, или можно обойтись атомарными типами или каналами? Нужен ли отдельный тип ошибки, или хватит строки с контекстом? Что произойдёт, если эта операция упадёт? Нужен ли здесь async, или это синхронная операция?

Иногда самое правильное решение — самое простое.


Размещайте облачную инфраструктуру и масштабируйте сервисы с надежным облачным провайдером Beget.
Эксклюзивно для читателей Хабра мы даем бонус 10% при первом пополнении.

Воспользоваться

1111111111