Введение: почему глобальный синглтон в 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% операций — чтение