线程安全单例模式:从混乱到优雅
单例模式,说白了就是一个类只能有一个实例。听起来简单,但在多线程环境下,这事就变得微妙了。我最早接触这个模式是在一个游戏服务器项目里,当时负责日志模块——整个系统只需要一个日志实例,谁都能写,但不能写乱。嗯,那时候我还没意识到,后面会有这么多坑等着我。
为什么单例需要线程安全?
你想想看,两个线程同时调用 getInstance(),如果实例还没创建,它们可能各自 new 出一个对象。这就违反了单例的初衷。更糟糕的是,如果 A 线程刚分配了内存但还没构造完,B 线程就拿到了这个半成品指针——直接崩溃。
所以,线程安全的单例,核心就两个问题:
- 唯一性:只有一个实例被创建
- 可见性:所有线程看到的实例状态是一致的
经典方案一:双重检查锁定(DCLP)
这是很多 C++ 老手的第一反应。先检查一次,如果实例为空,再加锁,再检查一次。听起来很合理,对吧?
// 双重检查锁定(有问题的版本)
class Singleton {
private:
static Singleton* instance;
static std::mutex mtx;
public:
static Singleton* getInstance() {
if (instance == nullptr) { // 第一次检查
std::lock_guard<std::mutex> lock(mtx);
if (instance == nullptr) { // 第二次检查
instance = new Singleton();
}
}
return instance;
}
};
instance = new Singleton() 不是原子操作,它包含三步:分配内存、调用构造函数、赋值指针。编译器或 CPU 可能重排为:分配内存、赋值指针、调用构造函数。另一个线程看到指针非空,直接返回,但对象还没构造完。
我在一个高并发服务里就踩过这个坑。线上偶发崩溃,查了两天才定位到是 DCLP 的问题。后来加了内存屏障才解决。
修复版:加上内存屏障
// 使用 std::atomic 和内存序
class Singleton {
private:
static std::atomic<Singleton*> instance;
static std::mutex mtx;
public:
static Singleton* getInstance() {
Singleton* p = instance.load(std::memory_order_acquire);
if (p == nullptr) {
std::lock_guard<std::mutex> lock(mtx);
p = instance.load(std::memory_order_relaxed);
if (p == nullptr) {
p = new Singleton();
instance.store(p, std::memory_order_release);
}
}
return p;
}
};
这里的关键是 memory_order_acquire 和 memory_order_release。简单说:release 保证之前的写操作不会被重排到后面,acquire 保证之后的读操作不会被重排到前面。这样,其他线程看到指针非空时,一定能看到完整的构造结果。
方案二:Meyer's Singleton(局部静态变量)
Scott Meyer 在《Effective C++》里提过一个优雅的方案:利用函数内的静态变量。
// Meyer's Singleton
class Singleton {
public:
static Singleton& getInstance() {
static Singleton instance; // 局部静态变量
return instance;
}
private:
Singleton() = default;
~Singleton() = default;
Singleton(const Singleton&) = delete;
Singleton& operator=(const Singleton&) = delete;
};
这个方案在 C++11 之前也是不安全的——多个线程同时首次进入函数,可能构造多次。但 C++11 标准规定:局部静态变量的初始化是线程安全的。编译器会自动加锁,保证只初始化一次。
我个人非常喜欢这个方案。代码简洁,没有显式加锁,没有内存屏障,读起来就像普通代码。我在好几个项目里都用它,从来没出过问题。
- 代码极简,只有几行
- 线程安全由标准保证
- 延迟初始化,按需创建
- 没有动态内存分配,不会内存泄漏
方案三:C++11 的线程安全初始化
C++11 引入了 std::call_once 和 std::once_flag,专门解决「只执行一次」的问题。
// 使用 std::call_once
class Singleton {
private:
static Singleton* instance;
static std::once_flag flag;
public:
static Singleton* getInstance() {
std::call_once(flag, []() {
instance = new Singleton();
});
return instance;
}
};
std::call_once 保证传入的函数只被执行一次,即使多个线程同时调用。它的内部实现通常比手写 DCLP 更高效,而且不容易出错。
我记得有一次代码审查,看到新人手写了一个复杂的 DCLP,我直接建议他换成 call_once。他一开始还担心性能,后来 benchmark 发现 call_once 在无竞争时几乎零开销,有竞争时也比手写版本稳定。
三种方案对比
| 方案 | 代码复杂度 | 线程安全 | 延迟初始化 | 控制销毁 | 推荐场景 |
|---|---|---|---|---|---|
| DCLP + 内存屏障 | 高 | 需小心实现 | ✅ | ✅ | 对性能极致要求,且熟悉内存模型 |
| Meyer's Singleton | 极低 | C++11 保证 | ✅ | ❌ | 大多数场景,首选 |
| std::call_once | 低 | 标准保证 | ✅ | ✅ | 需要手动管理生命周期时 |
核心知识体系
下面这张图总结了单例模式线程安全的核心脉络:
避坑指南
我曾经在代码里看到过这样的「骚操作」:
// 错误示例:不要这样做!
static Singleton* instance = nullptr;
if (!instance) {
instance = new Singleton(); // 没有锁,线程不安全
}
这种代码在单线程下没问题,一上多线程就崩。还有的人用 volatile 来修饰指针,以为能解决可见性问题——其实 volatile 在 C++ 里跟线程安全半毛钱关系都没有,它只是告诉编译器不要优化这个变量的读写。
std::call_once。至于手写 DCLP——除非你在写标准库,否则真的没必要。
嗯,单例模式就聊到这里。记住一句话:能用简单的,就别用复杂的。线程安全不是靠堆代码堆出来的,而是靠对并发模型的理解。