在C++多线程程序里,单例模式如果写得不对,会让多个线程同时穿过判空逻辑,结果构造出好几个实例,不仅浪费内存,还可能因为共享资源被重复初始化而引发数据错乱。线程安全版本的核心目标,是保证无论多少线程同时调用获取接口,全局都只存在唯一对象,且构造过程不会出现竞态。下面我们从几种主流写法出发,把原理、代码和坑点都讲清楚。

基于Magic Static的线程安全单例
C++11标准规定,函数内的局部静态变量初始化是线程安全的,编译器会自动插入隐藏的同步逻辑,确保多个线程同时首次进入函数时,只有一个线程执行构造,其余线程阻塞等待构造完成。这种写法被称为Magic Static,也是目前最推荐的方式,因为它代码极短,而且没有显式的锁操作,不容易出错。
它的实现只依赖一个静态局部变量,不需要成员变量保存指针,也不需要析构时手动释放,因为程序退出时静态对象会被自动销毁。下面的例子展示了一个基础的日志管理器单例,任何线程调用Logger::instance()拿到的都是同一个对象。
#include <iostream>
#include <string>
class Logger {
public:
static Logger& instance() {
static Logger inst;
return inst;
}
void write(const std::string& msg) {
std::cout << msg << std::endl;
}
private:
Logger() { /* 初始化文件或控制台 */ }
Logger(const Logger&) = delete;
Logger& operator=(const Logger&) = delete;
};
// 线程中调用
// Logger::instance().write("hello from thread");
这种方案的优点非常明显:代码量少,语义清晰,而且由标准保证安全,不依赖程序员对内存模型的深入理解。缺点是,如果单例构造抛出了异常,再次调用instance()时会重新尝试构造,在某些场景下可能需要额外处理。另外在C++11之前的老编译器里,这种写法并不安全,需要使用后面的加锁方案。
使用std::call_once与std::once_flag的实现
当我们需要在构造时执行较复杂的逻辑,或者希望把实例指针作为类成员保存以便控制生命周期时,可以使用std::call_once。它接受一个std::once_flag引用和一个可调用对象,保证该可调用对象在进程内只执行一次,且多线程下不会重入。这种方式比自己写互斥锁更不容易写出bug。
下面的代码把实例放在堆上,并用std::unique_ptr管理,构造动作被包在init函数里传给std::call_once。即使十个线程同时第一次调用get(),init也只运行一次,其余线程拿到的是已构造好的指针。
#include <memory>
#include <mutex>
class Config {
public:
static Config* get() {
std::call_once(flag_, &Config::init);
return ptr_.get();
}
private:
static void init() {
ptr_.reset(new Config());
}
static std::unique_ptr<Config> ptr_;
static std::once_flag flag_;
Config() {}
};
std::unique_ptr<Config> Config::ptr_;
std::once_flag Config::flag_;
这种写法的可控性更强,比如你可以把ptr_换成原始指针并在程序退出时做特定顺序的清理,适合那些依赖其他单例的复杂系统。性能方面,std::call_once在首次调用后有标志位判断,开销极小。要注意的是flag_和ptr_都必须是静态成员,并且在类外定义,否则链接时会报错。
双重检查锁定与原子变量的正确用法
在C++11之前,很多项目用双重检查锁定来减少锁竞争:先判空,再加锁,进锁后再判空。但早期的朴素写法用普通指针加pthread_mutex,会因为指令重排导致线程拿到未构造完的对象。现代正确版本必须用std::atomic并且指定内存序,才能保证可见性与有序性。
下面给出一个符合C++11内存模型的双重检查锁定实现。第一层判空是无锁的快捷路径,第二层在锁内再次判空防止重复构造,而std::atomic的memory_order_acquire和memory_order_release配对,确保对象写入对其他线程可见时,指针赋值已经完成。
#include <atomic>
#include <mutex>
class Singleton {
public:
static Singleton* get() {
Singleton* tmp = instance_.load(std::memory_order_acquire);
if (tmp == nullptr) {
std::lock_guard<std::mutex> lock(mtx_);
tmp = instance_.load(std::memory_order_relaxed);
if (tmp == nullptr) {
tmp = new Singleton();
instance_.store(tmp, std::memory_order_release);
}
}
return tmp;
}
private:
static std::atomic<Singleton*> instance_;
static std::mutex mtx_;
Singleton() {}
};
std::atomic<Singleton*> Singleton::instance_{nullptr};
std::mutex Singleton::mtx_;
双重检查锁定在极高并发且对首次获取延迟敏感的场景仍有价值,因为大多数调用只会走第一层原子读,不会碰锁。但它的代码复杂度高,一旦内存序写错就会引入极难排查的崩溃。因此在新项目中,优先选Magic Static,只有遇到特殊的生命周期或平台约束时,才考虑这种写法。
不同方案的选择与常见误区
从可维护性看,Magic Static几乎总是首选,它不需要静态成员定义,也不会有忘记初始化once_flag的问题。如果你的项目必须支持C++98且无法升级编译器,那就只能用加锁的懒汉或饿汉模式,并仔细测试。饿汉模式在程序启动就创建对象,虽避开了线程竞争,但会拖慢启动且可能带来静态初始化顺序灾难。
一个常见误区是以为加了volatile就能让普通指针的 double checked locking 变安全,这是错误的,volatile不提供线程间的内存可见性保证,只有std::atomic或标准库的同步原语才行。另一个坑是在析构里调用别的单例,如果那个单例已经被销毁,就会访问野指针,所以跨单例依赖要尽量用智能指针或明确销毁顺序。
总结来说,写线程安全单例不需要炫技,用C++11的Magic Static可以一行静态变量解决绝大多数需求;需要精细控制就用std::call_once;只有历史代码或极端性能调优才碰双重检查锁定,且必须搭配原子操作和正确内存序。理解这些差异,才能在面试和实战中都给出稳妥的答案。