4. 创建型模式-单例模式:Rust中实现单例的多种方式、OnceCell、LazyLock、线程安全考量

单例模式,说白了就是保证一个类只有一个实例。在 Rust 里,这事没那么简单。

我刚开始用 Rust 做项目时,第一个坑就踩在全局状态上。你想想看,Rust 的所有权模型天生就不喜欢全局可变状态。但现实项目中,日志、配置、数据库连接池,这些东西确实需要全局唯一访问点。

今天咱们就聊聊,在 Rust 里怎么优雅地实现单例。

为什么 Rust 的单例特别难搞?

传统 OOP 语言里,单例就是私有构造函数加一个静态方法。但 Rust 没有静态变量初始化时的线程安全保证,也没有类的构造函数概念。

核心难点有三个:

  • 全局可变状态:Rust 的全局变量必须是 const 或 static,且必须实现 Sync。想可变?得用 Mutex 或原子操作。
  • 初始化时机:静态变量在编译期就确定了值,没法在运行时动态初始化。除非用 lazy 初始化。
  • 生命周期:全局变量必须是 'static 生命周期,这意味着它不能引用任何非 'static 的数据。

嗯,这里要注意,Rust 的设计哲学就是「显式优于隐式」。单例这种隐式全局状态,本来就不太符合 Rust 的风格。但项目需要,咱们就得有办法。

方式一:最简单的常量单例

如果你的单例不需要可变状态,那最简单不过了:

pub struct Config {
    pub app_name: &'static str,
    pub version: u32,
}

pub const GLOBAL_CONFIG: Config = Config {
    app_name: "MyApp",
    version: 1,
};

这其实不算严格意义上的单例,但它确实保证了全局唯一且不可变。我在项目中经常用这种方式存配置常量。

方式二:OnceCell 与 LazyLock

需要运行时初始化的单例,OnceCell 是标准答案。Rust 1.70 之后,标准库直接提供了 std::cell::OnceCellstd::sync::OnceLock

我个人习惯用 OnceLock,因为它既线程安全又支持静态变量:

use std::sync::OnceLock;

static DB_POOL: OnceLock<DatabasePool> = OnceLock::new();

pub fn get_db_pool() -> &'static DatabasePool {
    DB_POOL.get_or_init(|| {
        DatabasePool::new("postgres://localhost:5432/mydb")
    })
}

为什么会这样?OnceLock 内部用了原子操作,保证初始化函数只执行一次。多个线程同时调用 get_or_init,只有一个会执行初始化,其他线程会等待。

小提示:OnceCell 是单线程版本,OnceLock 是多线程版本。别搞混了,否则在并发环境下会出问题。

方式三:LazyLock 的妙用

Rust 1.80 引入了 std::sync::LazyLock,它比 OnceLock 更简洁:

use std::sync::LazyLock;

static LOGGER: LazyLock<Logger> = LazyLock::new(|| {
    Logger::new("app.log")
});

fn main() {
    LOGGER.info("应用启动");
}

LazyLock 在第一次访问时自动初始化,之后直接返回已创建好的实例。它内部其实就是 OnceLock 的封装,但用起来更顺手。

我记得有一次,团队里有人用 LazyLock 初始化了一个很大的配置对象,结果在测试时发现每次运行测试都会重新初始化。后来才发现,LazyLock 的初始化是惰性的,但一旦初始化就不会释放。这在单元测试中要注意隔离性。

方式四:第三方库 once_cell

如果你的 Rust 版本低于 1.70,或者需要更多功能,once_cell 库是标准选择:

use once_cell::sync::Lazy;

static CONFIG: Lazy<Config> = Lazy::new(|| {
    Config::load_from_file("config.toml")
});

这个库在 Rust 生态里用了很多年,稳定可靠。现在标准库有了,但 once_cell 仍然有它的优势,比如支持 unsync 版本,适合单线程场景。

线程安全考量

单例的线程安全,说白了就两件事:

  1. 初始化安全:保证初始化只执行一次,且线程安全。
  2. 访问安全:保证对单例的读写操作是线程安全的。

对于可变单例,通常需要 Mutex 或 RwLock:

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

static COUNTER: LazyLock<Mutex<u32>> = LazyLock::new(|| {
    Mutex::new(0)
});

fn increment() {
    let mut count = COUNTER.lock().unwrap();
    *count += 1;
}
避坑指南:我曾经在项目中用 RwLock 包装了一个全局缓存,结果在高并发下出现了写者饥饿。后来改用 Mutex + 分片锁才解决。记住,RwLock 适合读多写少的场景,写多读少时反而比 Mutex 慢。

实战中的选择建议

场景 推荐方式 理由
不可变配置 const 或 LazyLock 简单、零开销
可变单例 OnceLock + Mutex 标准库支持,线程安全
需要异步初始化 tokio::sync::OnceCell 支持 async 初始化
测试隔离 避免全局单例,用依赖注入 测试时容易 mock

你想想看,单例模式在 Rust 里其实是个「反模式」。它破坏了所有权和借用规则,让代码变得难以测试。我个人建议,能用依赖注入就别用单例。但有些场景确实绕不开,比如日志系统、全局配置。

核心知识体系

下面这张图总结了 Rust 中实现单例的几种方式及其关系:

Rust 单例模式实现方式 单例模式 不可变单例 const 常量 LazyLock 可变单例 OnceLock + Mutex OnceLock + RwLock 线程安全考量 初始化安全 访问安全

嗯,总结一下。Rust 里实现单例,核心就是解决「全局 + 可变 + 线程安全」这三个问题。OnceLock 和 LazyLock 是标准答案,但别忘了,有时候不用单例才是最好的设计。

我在项目中见过太多滥用单例导致的问题:测试无法并行、隐式依赖难以追踪、全局状态污染。所以,用之前先问问自己:这个真的需要全局唯一吗?能不能用依赖注入?

核心原则:单例是手段,不是目的。能用局部变量就别用全局,能用依赖注入就别用单例。当不得不使用时,优先选择标准库的 OnceLock 或 LazyLock。

公众号:蓝海资料掘金营,微信deep3321