如何在 C++ 中实现线程安全的单例模式(使用 C++17 以上)

在多线程环境下,单例模式的实现需要保证在所有线程中只会生成一份实例,并且实例的创建是线程安全的。C++17 之后,标准库已经提供了对 std::call_oncestd::once_flag 的支持,结合 std::unique_ptr 可以非常简洁地实现线程安全的单例。

下面给出一种常见且推荐的实现方式,并对关键点进行详细说明。

1. 基本思路

  1. 延迟初始化
    只在第一次访问单例时才创建实例,而不是在程序启动时就创建。这样可以避免不必要的资源占用。

  2. 线程安全的初始化
    std::call_once 会保证传入的 lambda 或函数只会被调用一次,其他线程在此期间会被阻塞,直至第一次调用完成。

  3. 智能指针管理
    使用 std::unique_ptr 来管理单例实例,避免手动 delete,提升安全性。

2. 代码示例

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

class Logger {
public:
    // 禁止拷贝和移动
    Logger(const Logger&) = delete;
    Logger& operator=(const Logger&) = delete;
    Logger(Logger&&) = delete;
    Logger& operator=(Logger&&) = delete;

    static Logger& getInstance() {
        std::call_once(initFlag, []() {
            instance.reset(new Logger);
        });
        return *instance;
    }

    void log(const std::string& message) {
        std::lock_guard<std::mutex> lock(logMutex);
        std::cout << "[LOG] " << message << std::endl;
    }

private:
    Logger() { std::cout << "Logger constructed\n"; }
    ~Logger() { std::cout << "Logger destructed\n"; }

    static std::unique_ptr <Logger> instance;
    static std::once_flag initFlag;

    std::mutex logMutex; // 用于保护 log 方法内部的临界区
};

// 静态成员定义
std::unique_ptr <Logger> Logger::instance = nullptr;
std::once_flag Logger::initFlag;

说明

  • 构造函数私有化:保证外部无法直接实例化。
  • 禁止拷贝/移动:防止复制单例实例。
  • getInstance():使用 std::call_once 进行一次性初始化。返回引用以便直接使用。
  • 线程安全的 log:使用 std::mutex 对输出进行同步,避免多线程输出混乱。
  • 静态成员instanceinitFlag 必须在类外定义。

3. 多线程测试

#include <thread>
#include <vector>

void worker(int id) {
    auto& logger = Logger::getInstance();
    logger.log("Thread " + std::to_string(id) + " started");
}

int main() {
    std::vector<std::thread> threads;
    for (int i = 0; i < 10; ++i) {
        threads.emplace_back(worker, i);
    }
    for (auto& th : threads) {
        th.join();
    }
    return 0;
}

运行结果示例:

Logger constructed
[LOG] Thread 0 started
[LOG] Thread 1 started
...
[LOG] Thread 9 started

可以看到,构造器只被调用一次,且日志输出顺序虽然不确定,但不会出现交叉。

4. 进一步优化

  • 懒汉式 vs 饿汉式
    上述实现为懒汉式(按需创建)。如果单例创建成本很低且不关心延迟初始化,可以改为饿汉式:

    static Logger instance;
  • 静态局部变量
    C++11 起,函数内的静态局部变量初始化已保证线程安全,可以进一步简化:

    static Logger& getInstance() {
        static Logger instance;
        return instance;
    }

    但若需要显式控制销毁顺序,或者在构造时需要抛异常,建议使用 std::call_once

  • 自定义销毁顺序
    如需在程序结束前显式销毁单例,可使用 std::atexit 注册销毁函数:

    std::atexit([](){ Logger::getInstance().~Logger(); });

5. 常见错误与陷阱

  1. 忘记 static 修饰符
    instanceinitFlag 必须是静态成员,否则会导致每个实例都有自己的 flag,失去单例效果。

  2. 多线程竞争 logMutex
    如果 log 方法内部有其他共享资源访问,也要加锁,否则会产生竞态条件。

  3. 使用裸指针
    直接 new Logger 并手动 delete,容易出现内存泄漏或双重释放。使用 std::unique_ptr 可避免。

  4. 析构顺序问题
    在多线程程序中,若主线程结束后仍有工作线程使用单例,需保证单例在所有线程结束后才被销毁。

6. 小结

通过 std::call_oncestd::once_flag,C++17 之后可以轻松实现线程安全的单例模式。结合 std::unique_ptr,可以避免手动内存管理的错误。只需注意构造函数私有化、禁止拷贝/移动以及正确的静态成员定义,即可得到一种简洁、可维护且高效的单例实现。

发表评论