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

在多线程环境下,单例模式的实现需要保证以下两点:

  1. 只创建一次实例。
  2. 多线程并发访问时不产生竞争。

下面给出几种常见的实现方式,并说明各自的优缺点。


1. 双重检查锁(Double‑Checked Locking)

class Singleton {
private:
    static std::atomic<Singleton*> instance_;
    static std::mutex mutex_;

    Singleton() = default;          // 私有构造函数
public:
    Singleton(const Singleton&) = delete;
    Singleton& operator=(const Singleton&) = delete;

    static Singleton* getInstance() {
        Singleton* tmp = instance_.load(std::memory_order_acquire);
        if (!tmp) {                         // 第一次检查
            std::lock_guard<std::mutex> lock(mutex_);
            tmp = instance_.load(std::memory_order_relaxed);
            if (!tmp) {                     // 第二次检查
                tmp = new Singleton();
                instance_.store(tmp, std::memory_order_release);
            }
        }
        return tmp;
    }
};

std::atomic<Singleton*> Singleton::instance_{nullptr};
std::mutex Singleton::mutex_;

优点

  • 只在第一次访问时加锁,后续访问性能几乎与单线程无差。

缺点

  • 代码较为复杂,容易出现指令重排导致的未定义行为。
  • 对于 C++11 之前的编译器(如某些老版本的 GCC)可能不安全。

2. 只使用局部静态变量(Meyers 单例)

class Singleton {
private:
    Singleton() = default;
public:
    Singleton(const Singleton&) = delete;
    Singleton& operator=(const Singleton&) = delete;

    static Singleton& getInstance() {
        static Singleton instance;   // 线程安全的局部静态初始化
        return instance;
    }
};

优点

  • 代码简洁。
  • 从 C++11 开始,局部静态变量的初始化是线程安全的。
  • 在销毁时自动析构,避免了内存泄漏。

缺点

  • 对于大对象或高频访问的单例,可能在第一次访问时产生较大延迟。
  • 在某些环境下(如使用 -fno-threadsafe-statics 编译器选项)不保证线程安全。

3. 采用 std::call_once

class Singleton {
private:
    Singleton() = default;
    static Singleton* instance_;
    static std::once_flag initFlag_;

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

    static Singleton* getInstance() {
        std::call_once(initFlag_, [](){
            instance_ = new Singleton();
        });
        return instance_;
    }
};

Singleton* Singleton::instance_ = nullptr;
std::once_flag Singleton::initFlag_;

优点

  • 代码既清晰又可靠。
  • std::call_once 只会执行一次初始化,且内部已实现线程安全。
  • Meyers 单例相比,call_once 可以在任何函数中使用(不局限于返回引用)。

缺点

  • 需要手动管理 instance_ 的生命周期,可能导致内存泄漏。
  • 需要额外的 once_flag 成员。

4. 对象销毁与内存泄漏的处理

  • Meyers 单例:在程序结束时自动析构。
  • 双重检查锁与 call_once:如果需要在程序结束时析构单例,可以使用 std::shared_ptr 或在 atexit 注册析构函数。
static std::unique_ptr <Singleton> instance;

static void destroyInstance() {
    instance.reset();
}

5. 性能评估

方法 加锁次数 线程安全 代码复杂度
双重检查锁 1(首次) ★★
Meyers 单例 0
call_once 1(首次) ★★

从性能角度看,Meyers 单例 由于不需要加锁,最适合高频访问的场景;而 双重检查锁call_once 则在需要显式控制实例生命周期时更有优势。


6. 结语

在现代 C++(C++11 及以后)中,最推荐使用 Meyers 单例std::call_once 的实现方式。它们既简洁又保证线程安全,同时避免了手动内存管理带来的风险。若项目使用的编译器不支持 C++11,或需要在更旧的标准下工作,则可以考虑使用双重检查锁,但需注意平台特性与内存模型。

选择哪种实现方式,主要取决于项目对性能、可维护性和生命周期控制的具体需求。

发表评论