Глобальный синглтон в Rust: ломаем стереотипы с кодом

Онлайн-тренажер Python для начинающих

Изучайте Python легко и без перегрузки теорией. Решайте практические задачи с автоматической проверкой, получайте подсказки на русском языке и пишите код прямо в браузере — без необходимости что-либо устанавливать.

Начать курс

Введение: почему глобальный синглтон в Rust — это вызов?

Если вы пришли из мира Python, JavaScript или C++, то привыкли к простому глобальному объекту. Объявил переменную на уровне модуля — и она твоя. В Rust такой фокус не пройдёт. Компилятор строго следит за временем жизни и доступом к данным, особенно если они могут меняться из разных потоков. Именно поэтому на Stack Overflow вопрос «How do I create a global, mutable singleton?» собрал тысячи просмотров и десятки сложных ответов.

Но не спешите пугаться. Синглтон в Rust — это не боль, а дисциплина. Сегодня мы разберём, как создать глобальный изменяемый синглтон безопасно, эффективно и с пониманием происходящего. Вы узнаете, почему static mut — зло, и какие инструменты (lazy_static, OnceCell, Mutex) дают нам контроль.

Статья написана для образовательного сайта pythonlib.ru и адаптирует реальный вопрос с SO под практический урок. Поехали!

Проблема: что такое глобальный изменяемый синглтон?

Синглтон — это паттерн, при котором класс или структура имеет ровно один экземпляр во всей программе. В контексте Rust мы говорим о глобальной переменной, доступной из любого места кода, которую можно изменять (mut).

Примеры использования:

  • Конфигурация приложения, загруженная один раз при старте.
  • Глобальный логгер или счётчик событий.
  • Пул соединений с базой данных.
  • Кэш в памяти (in-memory cache).

Проблема в том, что Rust запрещает глобальные изменяемые переменные без специальной обёртки. Почему? Потому что они приводят к гонкам данных (data races) и неопределённому поведению. Компилятор не может доказать, что два потока не будут одновременно писать в одну ячейку памяти.

Наивное решение выглядит так:

// ПЛОХОЙ код — не делайте так!
static mut COUNTER: u32 = 0;

fn increment() {
    unsafe {
        COUNTER += 1;
    }
}

Это работает, но требует unsafe и оставляет вас один на один с гонками. В многопоточной среде — это бомба замедленного действия. Нам нужен безопасный, идиоматичный подход.

Инструмент 1: lazy_static + Mutex — классика жанра

Самый популярный способ создать глобальный изменяемый синглтон — это связка lazy_static (ленивая инициализация) и Mutex (взаимное исключение).

Добавьте зависимости в Cargo.toml:

[dependencies]
lazy_static = "1.4"

Теперь код:

use lazy_static::lazy_static;
use std::sync::Mutex;

lazy_static! {
    static ref GLOBAL_CONFIG: Mutex<Config> = Mutex::new(Config::default());
}

#[derive(Debug)]
struct Config {
    db_url: String,
    max_connections: u32,
}

impl Default for Config {
    fn default() -> Self {
        Config {
            db_url: "localhost:5432".to_string(),
            max_connections: 10,
        }
    }
}

fn main() {
    // Чтение
    let config = GLOBAL_CONFIG.lock().unwrap();
    println!("Текущий URL: {}", config.db_url);

    // Запись
    // drop(config); // явно отпускаем блокировку
    let mut config = GLOBAL_CONFIG.lock().unwrap();
    config.db_url = "new-host:8888".to_string();
    println!("Новый URL: {}", config.db_url);
}

Как это работает?

  • lazy_static! гарантирует, что инициализация произойдёт только один раз при первом обращении.
  • Mutex<T> обеспечивает взаимное исключение: только один поток может получить доступ к данным в любой момент времени.
  • Метод .lock() возвращает MutexGuard, который автоматически освобождает блокировку при выходе из области видимости.

Плюсы: простота, проверено временем, безопасно для потоков.

Минусы: каждая операция требует захвата блокировки (оверхед), нельзя использовать в статическом контексте без lazy_static.

Инструмент 2: OnceCell + Mutex — современный стандарт

С Rust 1.70 в стандартной библиотеке появился std::sync::OnceLock (он же OnceCell в крейте once_cell). Это более идиоматичный способ ленивой инициализации без макросов.

Добавляем крейт (если используете старый Rust):

[dependencies]
once_cell = "1.19"

А вот пример с OnceLock из std (Rust 1.70+):

use std::sync::{Mutex, OnceLock};

fn global_config() -> &'static Mutex<Config> {
    static CONFIG: OnceLock<Mutex<Config>> = OnceLock::new();
    CONFIG.get_or_init(|| Mutex::new(Config::default()))
}

#[derive(Debug)]
struct Config {
    db_url: String,
}

impl Default for Config {
    fn default() -> Self {
        Config { db_url: "default:5432".to_string() }
    }
}

fn main() {
    let config = global_config().lock().unwrap();
    println!("URL: {}", config.db_url);
}

Важное отличие: OnceLock не требует макроса, инициализация происходит при вызове get_or_init. Это даёт больше гибкости: вы можете передавать параметры при инициализации.

Если вам нужна атомарная инициализация без Mutex (только для неизменяемых данных), используйте OnceLock<T> напрямую. Но для изменяемого синглтона — комбинация с Mutex обязательна.

Инструмент 3: RwLock — когда читателей больше, чем писателей

Если ваш синглтон часто читается, но редко изменяется, используйте RwLock (read-write lock). Он позволяет множеству потоков одновременно читать данные, но блокирует всех при записи.

use std::sync::{RwLock, OnceLock};

fn global_stats() -> &'static RwLock<Stats> {
    static STATS: OnceLock<RwLock<Stats>> = OnceLock::new();
    STATS.get_or_init(|| RwLock::new(Stats::new()))
}

struct Stats {
    requests: u64,
}

impl Stats {
    fn new() -> Self { Stats { requests: 0 } }
}

fn main() {
    // Чтение (много потоков могут читать одновременно)
    let read = global_stats().read().unwrap();
    println!("Запросов: {}", read.requests);
    drop(read);

    // Запись (блокирует всех читателей)
    let mut write = global_stats().write().unwrap();
    write.requests += 1;
}

Это повышает производительность, если 90% операций — чтение

Блоги

Рекомендаций по книгам