在多线程环境下,单例模式常被用来保证全局资源的唯一性。实现线程安全的单例需要仔细处理对象创建与访问的同步。下面给出一种常见且高效的实现——双检锁(Double-Checked Locking)方案,并说明其细节与可能的陷阱。
1. 需求与挑战
- 唯一性:保证同一进程内只创建一个实例。
- 懒加载:首次访问时才创建实例,避免不必要的开销。
- 线程安全:多线程并发访问时不能导致多次实例化。
- 性能:避免每次访问都进行昂贵的互斥操作。
2. 双检锁实现(C++11 以上)
#include <mutex>
class Singleton {
public:
// 获取实例
static Singleton& getInstance() {
// 第一次检查(未加锁)
if (instance_ == nullptr) {
std::lock_guard<std::mutex> lock(mutex_);
// 第二次检查(加锁后)
if (instance_ == nullptr) {
instance_ = new Singleton();
}
}
return *instance_;
}
// 禁止拷贝构造与赋值
Singleton(const Singleton&) = delete;
Singleton& operator=(const Singleton&) = delete;
private:
Singleton() {} // 私有构造函数
~Singleton() { delete instance_; } // 析构时释放
static Singleton* instance_;
static std::mutex mutex_;
};
// 静态成员初始化
Singleton* Singleton::instance_ = nullptr;
std::mutex Singleton::mutex_;
关键点说明
instance_的声明:使用裸指针配合nullptr判断;C++11 起的std::atomic也可用于更细粒度的原子操作。- 第一次检查:快速路径,无锁访问,提高并发性能。
- 锁保护:仅在需要创建实例时才加锁,减少竞争。
- 第二次检查:确保在竞争时只有第一个线程真正创建实例。
3. 可能的陷阱
| 陷阱 | 说明 | 解决方案 |
|---|---|---|
| 指令重排 | 编译器或处理器可能在实例化过程中重新排列指令,导致 instance_ 在构造完成前就被写入,其他线程看到非空但未完成的对象。 |
① 在 C++11 以上使用 std::atomic<Singleton*> 并配合 memory_order_acquire/release。② 采用 Meyers Singleton(局部静态变量)方式,C++11 规范保证线程安全且不易受重排影响。 |
| 析构时机 | 静态全局对象的析构顺序不确定,可能导致 instance_ 在 main 结束前已被销毁。 |
使用 Meyers Singleton,静态局部对象会在程序退出时安全销毁,或者手动控制生命周期。 |
| 多线程并发创建 | 旧版 C++ 语义下,两个线程可能同时通过第一次检查进入 if,导致两次实例化。 |
上述双检锁代码已通过 mutex_ 解决;若使用 C++11 std::call_once 则更简洁。 |
4. 更简洁的实现(C++11 之后)
class Singleton {
public:
static Singleton& getInstance() {
static Singleton instance; // C++11 确保线程安全
return instance;
}
private:
Singleton() {}
Singleton(const Singleton&) = delete;
Singleton& operator=(const Singleton&) = delete;
};
该实现利用 函数内部静态变量 的特性,编译器自动处理初始化同步,避免手写锁。若需手动控制销毁时间,可配合 std::shared_ptr 或 std::unique_ptr。
5. 何时使用双检锁?
- 性能敏感:需要在高并发读场景下减少锁开销。
- 老旧编译器:不支持 C++11 静态局部初始化线程安全。
- 自定义销毁:想在程序退出前显式销毁单例。
6. 小结
双检锁实现虽然在 C++11 之后不再是首选,但在一些特殊需求(如需要手动销毁或旧环境)下仍有价值。正确使用 std::atomic 或 std::call_once 能显著提升代码的可靠性与可读性。掌握这些模式后,你可以在多线程 C++ 项目中安全、有效地实现全局唯一实例。