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

在多线程环境下,单例模式(Singleton)经常会成为资源共享的瓶颈。传统的单例实现虽然保证了全局唯一性,但在并发访问时可能会出现竞争条件,导致多次实例化或者线程不安全。下面我们从几个角度来探讨如何在C++中实现线程安全的单例模式,并给出几种常见的实现方式。


1. 经典 Meyers 单例(C++11 起)

class Singleton {
public:
    static Singleton& instance() {
        static Singleton instance; // C++11 起局部静态变量的初始化是线程安全的
        return instance;
    }
    // 禁止拷贝构造和赋值
    Singleton(const Singleton&) = delete;
    Singleton& operator=(const Singleton&) = delete;
private:
    Singleton() = default;
    ~Singleton() = default;
};

原理

  • 局部静态变量:在第一次进入 instance() 时,编译器会保证 instance 的构造是原子性的。自 C++11 起,标准保证多线程下局部静态变量的初始化是线程安全的。
  • 删除拷贝构造和赋值:防止通过拷贝或赋值创建新的实例。

优点

  • 代码简洁,易于维护。
  • 无需手动加锁或使用原子操作。
  • 延迟初始化:真正需要时才实例化。

缺点

  • 对于极低延迟要求或在构造过程中有异常抛出的情况,需要额外处理。
  • 对于单元测试,难以重置实例。

2. 双重检查锁(Double-Checked Locking)

class Singleton {
public:
    static Singleton* instance() {
        if (instance_ == nullptr) {                // 第一检查
            std::lock_guard<std::mutex> lock(mutex_);
            if (instance_ == nullptr) {            // 第二检查
                instance_ = new Singleton();
            }
        }
        return instance_;
    }
    // 其它成员...
private:
    Singleton() = default;
    ~Singleton() = default;
    static std::atomic<Singleton*> instance_;
    static std::mutex mutex_;
};

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

原理

  • 先检查指针是否为空,若为空则进入临界区。
  • 进入临界区后再次检查,确保没有其他线程已经创建实例。
  • 通过 std::atomic 保证可见性。

优点

  • 只在第一次访问时进行加锁,后续访问不受锁影响。

缺点

  • 需要手动维护 instance_mutex_
  • 如果构造函数抛出异常,instance_ 可能保持为 nullptr,导致后续访问仍然进入临界区。

3. 静态局部 + std::call_once

class Singleton {
public:
    static Singleton& instance() {
        std::call_once(initFlag_, []() {
            instance_ = new Singleton();
        });
        return *instance_;
    }
    // 其它成员...
private:
    Singleton() = default;
    ~Singleton() = default;
    static Singleton* instance_;
    static std::once_flag initFlag_;
};

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

原理

  • std::call_once 保证闭包只会执行一次,线程安全。
  • std::atomic 或手动加锁相比,更简洁。

优点

  • 简洁、易读,适合需要手动延迟初始化的场景。
  • 适用于 C++11 之前的编译器,只要支持 std::call_once

4. 延迟单例(Lazy Singleton)与智能指针

如果你需要在单例销毁时释放资源,可以使用 std::shared_ptrstd::weak_ptr

class Singleton {
public:
    static std::shared_ptr <Singleton> instance() {
        std::lock_guard<std::mutex> lock(mutex_);
        if (!ptr_) {
            ptr_ = std::shared_ptr <Singleton>(new Singleton());
        }
        return ptr_;
    }
private:
    Singleton() = default;
    static std::shared_ptr <Singleton> ptr_;
    static std::mutex mutex_;
};

std::shared_ptr <Singleton> Singleton::ptr_{nullptr};
std::mutex Singleton::mutex_;

说明

  • 通过 std::shared_ptr 自动管理生命周期,支持多次获取实例。
  • 线程安全地创建与销毁。

5. 性能考虑

  • Meyers 单例 在现代编译器下是最快的,因为只在第一次调用时加锁,且编译器会优化为局部静态构造。
  • 双重检查锁 可能因 std::atomic 的可见性开销略慢。
  • std::call_once 具有较好的性能,尤其在大多数调用不需要锁时。

6. 实践中的常见误区

  1. 在构造函数中访问全局单例
    这会导致构造函数执行时单例未完全初始化,产生未定义行为。建议将初始化放在 instance() 之外。

  2. 使用宏定义实现单例
    宏会隐藏错误,难以调试。推荐使用类封装。

  3. 不处理异常
    单例构造抛异常后,后续调用可能再次尝试创建实例。使用 std::call_oncetry/catch 进行保护。


7. 结语

在 C++11 之后,实现线程安全的单例几乎不再是难题。最推荐的做法是使用局部静态变量(Meyers 单例),因为其既简洁又符合标准。对于更复杂的需求,如手动销毁或多线程初始化控制,可以考虑 std::call_once 或双重检查锁方案。只要注意构造函数的异常安全性和生命周期管理,单例模式就能在多线程环境下保持稳定与高效。

发表评论