在多线程环境下,单例模式的实现需要保证在所有线程中只会生成一份实例,并且实例的创建是线程安全的。C++17 之后,标准库已经提供了对 std::call_once 和 std::once_flag 的支持,结合 std::unique_ptr 可以非常简洁地实现线程安全的单例。
下面给出一种常见且推荐的实现方式,并对关键点进行详细说明。
1. 基本思路
-
延迟初始化
只在第一次访问单例时才创建实例,而不是在程序启动时就创建。这样可以避免不必要的资源占用。 -
线程安全的初始化
std::call_once会保证传入的 lambda 或函数只会被调用一次,其他线程在此期间会被阻塞,直至第一次调用完成。 -
智能指针管理
使用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对输出进行同步,避免多线程输出混乱。 - 静态成员:
instance与initFlag必须在类外定义。
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. 常见错误与陷阱
-
忘记
static修饰符
instance与initFlag必须是静态成员,否则会导致每个实例都有自己的 flag,失去单例效果。 -
多线程竞争
logMutex
如果log方法内部有其他共享资源访问,也要加锁,否则会产生竞态条件。 -
使用裸指针
直接new Logger并手动delete,容易出现内存泄漏或双重释放。使用std::unique_ptr可避免。 -
析构顺序问题
在多线程程序中,若主线程结束后仍有工作线程使用单例,需保证单例在所有线程结束后才被销毁。
6. 小结
通过 std::call_once 与 std::once_flag,C++17 之后可以轻松实现线程安全的单例模式。结合 std::unique_ptr,可以避免手动内存管理的错误。只需注意构造函数私有化、禁止拷贝/移动以及正确的静态成员定义,即可得到一种简洁、可维护且高效的单例实现。