如何在C++20中实现线程安全的懒加载单例?

在 C++20 之前,单例模式的线程安全实现常常需要手动加锁或使用双重检查锁(double‑checked locking)。C++11 起引入了 std::call_oncestd::once_flag,以及对函数静态变量的线程安全初始化,极大简化了实现。

下面演示两种推荐方案,说明它们的原理、优点和适用场景。


1. 使用 std::call_once

#include <iostream>
#include <mutex>
#include <memory>

class Singleton {
public:
    static Singleton& instance() {
        std::call_once(initFlag, []() {
            instancePtr.reset(new Singleton());
        });
        return *instancePtr;
    }

    void sayHello() const { std::cout << "Hello from Singleton!\n"; }

private:
    Singleton() { std::cout << "Singleton ctor\n"; }
    ~Singleton() = default;

    Singleton(const Singleton&) = delete;
    Singleton& operator=(const Singleton&) = delete;

    static std::unique_ptr <Singleton> instancePtr;
    static std::once_flag initFlag;
};

std::unique_ptr <Singleton> Singleton::instancePtr;
std::once_flag Singleton::initFlag;

原理

  • std::call_once 接收一个 std::once_flag 和一个可调用对象(这里是 lambda)。
  • 第一次调用 instance() 时,call_once 会执行 lambda,并将 once_flag 标记为已初始化。
  • 后续调用 call_once 时会直接跳过 lambda,避免重复初始化。

优点

  • 显式控制:你可以在需要的地方决定初始化时机。
  • 延迟初始化:只有第一次访问 instance() 时才创建对象,省去无用构造。

缺点

  • 多次调用:如果 instance() 被多次并发调用,仍会多次检查 once_flag,虽然效率高,但略显冗余。

2. 直接使用函数内部静态变量

class Singleton {
public:
    static Singleton& instance() {
        static Singleton instance; // C++11 之后保证线程安全
        return instance;
    }

    void sayHello() const { std::cout << "Hello from Singleton!\n"; }

private:
    Singleton() { std::cout << "Singleton ctor\n"; }
    ~Singleton() = default;

    Singleton(const Singleton&) = delete;
    Singleton& operator=(const Singleton&) = delete;
};

原理

  • C++11 起,函数内部的 static 变量在第一次执行该函数时初始化,并且该初始化是 线程安全 的。
  • 后续调用直接使用已存在的实例。

优点

  • 代码最简洁:不需要额外的 once_flag 或指针。
  • 天然延迟:实例只在第一次调用时创建。
  • 编译器优化:编译器可以将 static 变量的访问视为只读,进一步提升性能。

缺点

  • 不可延迟销毁:实例会在程序终止时按逆序销毁,如果你需要在程序结束前手动销毁,需额外实现。
  • 构造错误:如果构造函数抛异常,后续调用仍会尝试重新初始化。

3. 何时选择哪种方式?

场景 推荐方案
需要在单例内部做复杂的资源初始化或异常处理 std::call_once + unique_ptr
只需要最简单的懒加载,且构造不抛异常 静态局部变量
需要自定义销毁顺序 std::call_once + 自定义 destroy()

4. 线程安全与 std::atomic 的误区

有人尝试使用 std::atomic<Singleton*> 作为单例指针,并用 load / store 进行原子访问。虽然技术上可行,但在实际使用中会出现未初始化指针读取的问题,除非再配合 std::call_oncestd::mutex,否则难以保证安全。

小结
C++20 及其之前版本都已提供完备的工具来实现线程安全的懒加载单例。最简洁的方案是使用函数内部的静态变量,而如果需要更细粒度的控制,std::call_once 则是最安全、最易维护的选择。无论哪种实现,都建议配合 delete 关键字禁用拷贝构造和赋值,以确保单例唯一性。

发表评论