在多线程环境下,传统的单例实现常常需要手动加锁或使用双重检查锁定(double-checked locking)来保证线程安全。C++11 及其之后的标准为此提供了更简单、更安全的工具——std::call_once 与 std::once_flag。本文将演示如何利用这两者在 C++17 中实现一个懒加载、线程安全且高效的单例类,并讨论其优缺点。
1. 需求与目标
- 懒初始化:单例对象在第一次使用时才创建,避免程序启动时不必要的开销。
- 线程安全:在多线程同时访问时只创建一次实例。
- 高效:在后续调用中不需要再进行锁竞争。
2. 核心工具
| 组件 | 作用 | 典型用法 |
|---|---|---|
std::once_flag |
记录一次性调用的状态 | std::once_flag flag; |
std::call_once |
只执行一次指定函数 | std::call_once(flag, []{ /* 初始化 */ }); |
std::call_once 的实现保证即使有多个线程同时调用,它也会让其中一个线程执行提供的 lambda(或函数),其余线程会等待,直到该 lambda 执行完毕。此时 once_flag 的状态被标记为已完成,后续对同一 flag 的调用将立即返回。
3. 代码实现
#include <iostream>
#include <mutex>
#include <memory>
#include <thread>
class Singleton {
public:
// 禁止拷贝与移动
Singleton(const Singleton&) = delete;
Singleton& operator=(const Singleton&) = delete;
Singleton(Singleton&&) = delete;
Singleton& operator=(Singleton&&) = delete;
static Singleton& instance() {
std::call_once(init_flag_, []() {
// 延迟初始化
instance_ptr_ = std::unique_ptr <Singleton>(new Singleton());
});
return *instance_ptr_;
}
void sayHello() const {
std::cout << "Hello from Singleton! Thread ID: " << std::this_thread::get_id() << '\n';
}
private:
Singleton() {
std::cout << "Singleton constructed in thread " << std::this_thread::get_id() << '\n';
}
static std::once_flag init_flag_;
static std::unique_ptr <Singleton> instance_ptr_;
};
// 静态成员定义
std::once_flag Singleton::init_flag_;
std::unique_ptr <Singleton> Singleton::instance_ptr_;
int main() {
// 让 10 个线程同时请求单例
std::vector<std::thread> threads;
for (int i = 0; i < 10; ++i) {
threads.emplace_back([]{
Singleton::instance().sayHello();
});
}
for (auto& t : threads) t.join();
return 0;
}
运行结果(示例):
Singleton constructed in thread 140245876023296
Hello from Singleton! Thread ID: 140245876023296
Hello from Singleton! Thread ID: 140245867630592
Hello from Singleton! Thread ID: 140245859237888
...
可见,Singleton 只被实例化一次,所有线程共享同一个对象。
4. 关键细节说明
-
std::unique_ptr用于持有实例
通过std::unique_ptr可以避免手动delete,并且在程序结束时自动销毁。 -
once_flag必须是静态
只有静态存储期的对象才会在多线程环境中保证同一实例。once_flag的生命周期必须覆盖整个程序。 -
异常安全
如果 lambda 中抛出异常,std::call_once会在该线程中记录异常,并在后续调用时重新抛出。这样可以防止因异常导致单例未正确初始化的情况。 -
懒加载与销毁顺序
std::unique_ptr在程序结束时按逆序析构,如果你需要自定义销毁顺序,可以考虑使用std::shared_ptr与自定义删除器。
5. 与传统实现对比
| 方案 | 线程安全 | 懒加载 | 代码复杂度 | 运行时开销 |
|---|---|---|---|---|
| 双重检查锁定(DCL) | 需要手动加锁,易出错 | 是 | 中 | 高(锁竞争) |
std::call_once |
原生线程安全 | 是 | 低 | 低(锁实现内部优化) |
| 静态局部变量 | 依赖编译器实现 | 否(即时) | 低 | 低 |
std::call_once 在现代编译器中通常会使用最小化锁策略(如二进制树锁),比手动 std::mutex 更高效。
6. 进阶话题
-
单例销毁
如果你想在程序结束前显式销毁单例,可以提供一个destroy()成员,并在调用后置空instance_ptr_。但要注意后续再次访问instance()时会重新创建。 -
多层次单例
有时需要在不同命名空间下维护各自的单例。可以将once_flag和unique_ptr放在对应的命名空间或类中。 -
C++20 的
std::atomic<std::shared_ptr>
对于需要多线程共享但不需要严格一次性初始化的场景,可以使用原子化的共享指针来实现。
7. 小结
std::call_once与std::once_flag为实现线程安全单例提供了简洁且高效的方式。- 只需要一次
std::call_once调用即可保证单例只被创建一次,后续访问不再需要锁竞争。 - 代码更易维护,异常安全性也得到提升。
希望本文能帮助你在 C++ 项目中正确、优雅地实现线程安全单例。祝编码愉快!