如何在C++中实现一个线程安全的单例模式?

在现代 C++(C++11 及以后)中,线程安全的单例实现变得相当简单。下面给出一种推荐的实现方式,并解释其内部机制。

1. 使用 std::call_oncestd::once_flag

#include <mutex>
#include <memory>

class Singleton
{
public:
    // 访问单例的全局接口
    static Singleton& instance()
    {
        // 1. static 局部变量在第一次进入函数时初始化
        //    C++11 规定此初始化是线程安全的
        static Singleton* instance_ptr = nullptr;
        static std::once_flag init_flag;

        // 2. std::call_once 确保 lambda 只执行一次
        std::call_once(init_flag, []{
            instance_ptr = new Singleton();
        });

        return *instance_ptr;
    }

    // 禁止复制和移动
    Singleton(const Singleton&) = delete;
    Singleton& operator=(const Singleton&) = delete;
    Singleton(Singleton&&) = delete;
    Singleton& operator=(Singleton&&) = delete;

    // 示例成员函数
    void do_something() const
    {
        // ...
    }

private:
    Singleton()  { /* 复杂初始化代码 */ }
    ~Singleton() { /* 清理资源 */ }
};

关键点说明

  1. *`static Singleton instance_ptr** 使用裸指针而非std::unique_ptrstd::shared_ptr`,因为在程序退出时我们不需要析构单例。裸指针更轻量,并避免在退出时产生析构顺序问题。

  2. std::once_flag + std::call_once
    std::once_flag 确保后续的 std::call_once 调用只会执行一次。即使多个线程同时调用 instance(),也只有一个线程会执行 lambda 初始化,其他线程会阻塞等待直到初始化完成。

  3. 删除复制/移动构造
    防止外部错误地复制或移动单例,保证唯一性。

2. 简化版本:直接使用 static 局部对象

如果你不介意在程序退出时自动销毁单例,甚至不想手动管理内存,可以直接使用:

class Singleton
{
public:
    static Singleton& instance()
    {
        static Singleton instance; // C++11 线程安全初始化
        return instance;
    }
    // ...
};

这种写法最简洁,但缺点是如果单例在 main 退出前仍然被引用,析构时可能会访问已被销毁的全局对象,导致“静态析构顺序”问题。

3. 与 C++20 std::atomic_flag 的组合

C++20 之后,你还可以使用 std::atomic_flag 替代 std::once_flag,实现更细粒度的控制:

static std::atomic_flag init_flag = ATOMIC_FLAG_INIT;
static Singleton* instance_ptr = nullptr;

if (!init_flag.test_and_set(std::memory_order_acquire)) {
    // 第一线程进入此分支,进行初始化
    instance_ptr = new Singleton();
    init_flag.clear(std::memory_order_release);
}

但相比 std::call_once 代码更繁琐,且易错,通常不推荐。

4. 性能注意

  • 第一次调用成本高:需要原子操作和可能的锁,初始化完成后后续调用几乎无开销。
  • 多线程竞争:如果多线程频繁访问 instance(),使用 std::call_once 的开销在第一次访问后几乎为零,随后几乎是纯读操作。

5. 常见陷阱

  • 忘记 delete 单例:如果你使用裸指针并且不在程序结束前 delete 它,可能导致内存泄漏。但由于单例的生命周期与程序相同,通常可以忽略释放。
  • 析构顺序:若单例内部持有其他全局对象,销毁顺序可能导致访问已销毁对象。使用 “创建时即使用” 或 “惰性删除” 可以规避。
  • 静态初始化顺序:如果某个模块在 main 之前就需要访问单例,确保单例的 instance() 调用在该模块的静态构造中不会导致提前实例化。

6. 结论

  • 推荐实现:使用 std::call_once + std::once_flag 的第一种模式。它既符合现代 C++ 标准,又能保证线程安全且易于维护。
  • 简化实现:若不介意析构顺序问题,直接使用 static 局部对象即可。
  • 性能优化:对性能极限的需求,建议使用 std::once_flag 并在单例中加入锁粒度控制。

通过上述方法,你可以在任何 C++11+ 项目中安全、简洁地实现线程安全的单例。

发表评论