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

在多线程环境下实现单例模式是一件棘手的事。传统的懒汉式单例可能在第一次访问时出现竞态条件,而饿汉式则会在程序启动时就创建对象,导致不必要的资源浪费。C++11引入的线程安全初始化特性,为我们提供了一种简洁、可靠且高效的实现方式。下面我们逐步演示如何使用C++11的std::call_oncestd::once_flag实现一个线程安全的单例,并对其性能与使用场景做进一步讨论。

1. 基本思路

  • 延迟初始化:只在第一次使用时才创建实例,避免无用资源浪费。
  • 线程安全:使用 std::call_once 确保初始化函数仅执行一次。
  • 无悬挂指针:使用局部静态变量或 std::unique_ptr 管理实例生命周期,防止未定义行为。

2. 代码实现

#include <iostream>
#include <mutex>
#include <thread>

class Logger
{
public:
    // 获取单例实例
    static Logger& instance()
    {
        // std::call_once 确保 init() 只被调用一次
        std::call_once(initFlag_, []() {
            instance_ = new Logger();
        });
        return *instance_;
    }

    // 记录日志
    void log(const std::string& msg)
    {
        std::lock_guard<std::mutex> lock(logMutex_);
        std::cout << "[" << threadId() << "] " << msg << std::endl;
    }

    // 禁止拷贝构造与赋值
    Logger(const Logger&) = delete;
    Logger& operator=(const Logger&) = delete;

private:
    Logger()  = default;
    ~Logger() = default;

    // 获取当前线程ID的字符串化表示
    std::string threadId()
    {
        std::ostringstream oss;
        oss << std::this_thread::get_id();
        return oss.str();
    }

    static std::once_flag initFlag_;
    static Logger* instance_;
    std::mutex logMutex_;
};

// 静态成员定义
std::once_flag Logger::initFlag_;
Logger* Logger::instance_ = nullptr;

// -----------------------
// 测试代码
// -----------------------
void worker(int id)
{
    Logger& logger = Logger::instance();
    logger.log("Thread " + std::to_string(id) + " is running.");
}

int main()
{
    std::vector<std::thread> threads;
    for (int i = 0; i < 5; ++i)
        threads.emplace_back(worker, i);

    for (auto& t : threads)
        t.join();

    return 0;
}

关键点说明

  1. std::call_once
    通过传递一个 lambda 表达式给 std::call_once,我们保证 instance_ 的初始化只会发生一次。即使有多条线程同时调用 instance(),也不会产生竞争。

  2. std::once_flag
    该标志位用于与 std::call_once 配合使用,标记初始化是否已完成。once_flag 的构造是默认初始化且线程安全。

  3. 静态指针 vs 静态局部变量
    这里使用 Logger* instance_ 来显式控制析构顺序。若改为 static Logger instance_;,其析构顺序由编译器决定,可能导致其他模块在程序结束时访问已析构对象。

  4. 互斥锁
    log() 方法内部使用 std::lock_guard 对日志输出进行同步,避免多线程并发写时产生乱序。

3. 性能分析

  • 延迟初始化:首次调用 instance() 时才进行一次昂贵的内存分配。后续调用不涉及任何锁操作,几乎无开销。
  • 无竞争std::call_once 在多线程环境下只会产生一次原子检查,随后线程直接获取已完成的实例,无需再次竞争。

与传统的 std::mutex + double-checked locking(DCL)方案相比,std::call_once 更简洁且完全依赖标准库,避免了微妙的内存模型错误。

4. 使用场景与注意事项

场景 适用性 说明
需要全局唯一资源(如日志、配置、数据库连接池) call_once 能保证安全
单例对象不需要在多线程间共享 代码更直观
对析构顺序有严格要求(如在 atexit 里访问单例) 需要显式释放或使用 std::unique_ptr

安全提示
如果单例对象在程序结束时需要销毁,并且有可能在 atexit 里被访问,最好使用 std::unique_ptrstd::call_once 结合,并在程序退出前手动 reset()。否则可能导致使用已析构对象。

5. 小结

C++11 的 std::call_oncestd::once_flag 为实现线程安全单例提供了最简洁、最可靠的手段。通过一次原子检查和一次初始化,既避免了传统双重检查锁定的陷阱,又能满足延迟初始化的需求。只要遵循禁用拷贝构造与赋值,结合必要的同步,便能在多线程应用中安全地使用单例模式。

发表评论