在多线程环境下实现单例模式是一件棘手的事。传统的懒汉式单例可能在第一次访问时出现竞态条件,而饿汉式则会在程序启动时就创建对象,导致不必要的资源浪费。C++11引入的线程安全初始化特性,为我们提供了一种简洁、可靠且高效的实现方式。下面我们逐步演示如何使用C++11的std::call_once与std::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;
}
关键点说明
-
std::call_once
通过传递一个 lambda 表达式给std::call_once,我们保证instance_的初始化只会发生一次。即使有多条线程同时调用instance(),也不会产生竞争。 -
std::once_flag
该标志位用于与std::call_once配合使用,标记初始化是否已完成。once_flag的构造是默认初始化且线程安全。 -
静态指针 vs 静态局部变量
这里使用Logger* instance_来显式控制析构顺序。若改为static Logger instance_;,其析构顺序由编译器决定,可能导致其他模块在程序结束时访问已析构对象。 -
互斥锁
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_ptr与std::call_once结合,并在程序退出前手动reset()。否则可能导致使用已析构对象。
5. 小结
C++11 的 std::call_once 与 std::once_flag 为实现线程安全单例提供了最简洁、最可靠的手段。通过一次原子检查和一次初始化,既避免了传统双重检查锁定的陷阱,又能满足延迟初始化的需求。只要遵循禁用拷贝构造与赋值,结合必要的同步,便能在多线程应用中安全地使用单例模式。