单例模式(Singleton)是一种常见的设计模式,保证某个类在程序中只存在一个实例,并提供全局访问点。由于 C++ 中的多线程编程越来越普及,实现线程安全的单例成为了实际开发中的重要需求。下面我们从几个角度出发,系统讲解如何在 C++ 中实现线程安全的单例,并比较几种常用实现方式的优缺点。
1. 基本单例结构
单例的核心思想是:
- 私有构造函数,禁止外部直接创建实例。
- 私有拷贝构造函数和赋值运算符,禁止拷贝。
- 静态成员函数
Instance()返回唯一实例。
class Singleton {
public:
static Singleton& Instance() {
static Singleton instance; // 线程安全实现见后续章节
return instance;
}
// 业务接口
void DoWork();
private:
Singleton() = default;
~Singleton() = default;
Singleton(const Singleton&) = delete;
Singleton& operator=(const Singleton&) = delete;
};
这里使用了 static Singleton instance; 在 Instance() 内部声明局部静态对象。C++11 之后,编译器保证该对象的初始化是线程安全的(见 §3),因此不必手动加锁。
2. 经典实现方式对比
| 实现方式 | 线程安全 | 代码简洁度 | 资源占用 | 适用场景 |
|---|---|---|---|---|
| 局部静态对象(C++11+) | ✅ | ★★★★ | 低 | 所有平台 |
std::call_once + std::once_flag |
✅ | ★★★ | 低 | 需要延迟初始化 |
| 双重检查锁(双检锁) | ❌(易出错) | ★★★ | 中 | 老旧 C++03 |
std::shared_ptr + std::atomic |
✅ | ★★ | 中 | 需要共享生命周期管理 |
下面逐一说明。
2.1 局部静态对象(推荐)
- 实现:如上所示,使用
static Singleton instance;。 - 优点:代码最简洁,且 C++11 之后编译器保证线程安全的初始化。
- 缺点:如果单例需要在全局析构期间被访问,可能导致“静态反序”问题。
2.2 std::call_once 与 std::once_flag
class Singleton {
public:
static Singleton& Instance() {
std::call_once(initFlag, [](){ instance.reset(new Singleton()); });
return *instance;
}
private:
Singleton() = default;
static std::unique_ptr <Singleton> instance;
static std::once_flag initFlag;
};
std::unique_ptr <Singleton> Singleton::instance;
std::once_flag Singleton::initFlag;
- 实现:使用一次性初始化标志,保证只初始化一次。
- 优点:可以使用
std::unique_ptr或std::shared_ptr控制生命周期,避免静态析构顺序问题。 - 缺点:略微增加代码量。
2.3 双重检查锁(双检锁)
传统的双重检查锁需要手动使用互斥锁,示例代码:
class Singleton {
public:
static Singleton* Instance() {
if (!instance) {
std::lock_guard<std::mutex> lock(mtx);
if (!instance) instance = new Singleton();
}
return instance;
}
private:
static Singleton* instance;
static std::mutex mtx;
};
- 问题:在 C++11 之前的编译器中,由于内存可见性问题,存在“指令重排序”导致未初始化对象被其它线程看到。C++11 的
std::atomic可以解决,但代码更复杂。
2.4 std::atomic + std::shared_ptr
使用原子指针来保证单例实例的可见性:
class Singleton {
public:
static std::shared_ptr <Singleton> Instance() {
std::shared_ptr <Singleton> tmp = instance.load(std::memory_order_acquire);
if (!tmp) {
std::lock_guard<std::mutex> lock(mtx);
tmp = instance.load(std::memory_order_relaxed);
if (!tmp) {
tmp = std::make_shared <Singleton>();
instance.store(tmp, std::memory_order_release);
}
}
return tmp;
}
private:
Singleton() = default;
static std::atomic<std::shared_ptr<Singleton>> instance;
static std::mutex mtx;
};
- 优点:提供共享所有权,避免析构顺序问题。
- 缺点:略高的运行时开销。
3. C++11 线程安全初始化细节
从 C++11 开始,标准规定局部静态对象的初始化是线程安全的:
- 第一次进入
Instance()时,编译器插入一次性初始化锁,保证只有一个线程能完成初始化。 - 其余线程会在
static对象完成初始化后直接返回。
这意味着即使 Instance() 被多个线程并发调用,也不会出现数据竞争。若使用的是 C++11 以前的编译器(如 GCC 4.6),需要手动加锁。
4. 静态析构顺序问题
如果单例在程序退出时被其他全局对象访问,可能导致“静态反序”问题。常用的避免策略:
- 使用
std::call_once+std::unique_ptr:单例对象在第一次使用时才分配,且存储在unique_ptr内,程序退出时由unique_ptr负责析构,避免反序问题。 - 使用
std::shared_ptr:将单例包装成共享指针,其他对象持有副本,生命周期得到统一管理。
5. 性能对比
- 局部静态对象:初始化时加锁开销,但锁只会在第一次调用时执行。之后访问是无锁的。
std::call_once:与局部静态相近,但可配合unique_ptr。- 双重检查锁:理论上每次访问都不加锁,但在现代 CPU 上实现困难,易出错。
- 原子 +
shared_ptr:额外的原子操作和共享计数,适用于需要多线程共享生命周期的场景。
6. 代码示例:完整的线程安全单例(推荐)
#include <memory>
#include <mutex>
class ConfigManager {
public:
static ConfigManager& Instance() {
static ConfigManager instance; // C++11 线程安全
return instance;
}
// 禁止拷贝与赋值
ConfigManager(const ConfigManager&) = delete;
ConfigManager& operator=(const ConfigManager&) = delete;
void LoadConfig(const std::string& path);
std::string GetValue(const std::string& key) const;
private:
ConfigManager() = default;
~ConfigManager() = default;
std::unordered_map<std::string, std::string> data_;
};
void ConfigManager::LoadConfig(const std::string& path) {
std::lock_guard<std::mutex> lock(mutex_);
// 简化示例:读取配置文件
}
std::string ConfigManager::GetValue(const std::string& key) const {
std::lock_guard<std::mutex> lock(mutex_);
auto it = data_.find(key);
return it != data_.end() ? it->second : std::string();
}
注意:如果
ConfigManager需要在main()之外的全局对象析构期间被访问,建议改用std::call_once+std::unique_ptr的实现方式。
7. 小结
- 在 C++11 之后,使用局部静态对象实现单例最为简单且线程安全。
- 若需要更细粒度的生命周期管理,推荐
std::call_once+std::unique_ptr或std::shared_ptr。 - 避免双重检查锁,除非在极端老旧环境下使用。
- 关注静态析构顺序问题,必要时使用智能指针包装。
通过本文的对比与示例,你可以在实际项目中根据需求选择最合适的线程安全单例实现方式。祝编码愉快!