在C++多线程开发中,单例模式是最常见的设计模式之一,但要让单例的初始化过程在多线程环境下绝对只执行一次且线程安全,并不是简单写一个if判断就能解决的。标准库自C++11起提供的std::call_once和std::once_flag,专门用来处理这种一次性初始化场景,能够确保传入的可调用对象在多线程并发调用时仅被执行一次,并且执行结果对所有线程立即可见。

一、为什么普通懒汉式单例不安全
很多初学者写单例时会采用指针判空加锁的方式,例如先判断实例是否为空,为空再进入加锁区创建。但即便加了互斥量,如果不处理好内存可见性和指令顺序,依然可能出现某个线程读到未完全构造的对象。更早期流行的双重检查锁定(DCLP)在C++11之前因为缺乏统一的内存模型,很容易因编译器或CPU重排而失效。
下面是一段典型的、在C++11之前有风险的双重检查代码思路(已做基础转义示意):
#include <mutex>
class Singleton {
public:
static Singleton* instance();
private:
static Singleton* ptr;
static std::mutex mtx;
};
Singleton* Singleton::ptr = nullptr;
std::mutex Singleton::mtx;
Singleton* Singleton::instance() {
if (ptr == nullptr) { // 第一次检查
std::lock_guard<std::mutex> lock(mtx);
if (ptr == nullptr) { // 第二次检查
ptr = new Singleton(); // 可能重排:先赋值指针后构造
}
}
return ptr;
}
上面的代码在旧标准中,new操作可能被拆分为分配内存、调用构造、赋值指针三步,而赋值可能排在构造之前,导致另一线程拿到未完成构造的实例。即使使用volatile也无法彻底解决,必须依赖标准库提供的一次性初始化机制。
二、call_once与once_flag基本用法
std::call_once定义在<mutex>头文件中,函数签名接受std::once_flag引用和一个可调用对象。它的语义是:多个线程同时调用call_once时,只有一个线程会真正执行目标函数,其余线程会阻塞直到该次执行完成;若执行抛出异常,则另选一个线程重试,直到成功执行为止。
结合单例模式,我们只需要把创建逻辑放进call_once调用的lambda或函数中,并声明一个静态的once_flag即可。示例如下:
#include <mutex>
#include <iostream>
class Logger {
public:
static Logger& get() {
static std::once_flag flag;
std::call_once(flag, []() {
instance = new Logger();
});
return *instance;
}
void log(const std::string& msg) {
std::cout << msg << std::endl;
}
private:
Logger() = default;
static Logger* instance;
};
Logger* Logger::instance = nullptr;
int main() {
auto& a = Logger::get();
auto& b = Logger::get();
a.log("hello");
return 0;
}
在这个例子中,flag必须是静态或生命周期长于线程的变量,否则每次调用都生成新flag会导致重复初始化。call_once内部已经处理了所有同步细节,我们不需要再手写锁。
三、利用局部静态变量简化写法
实际上,C++11规定了函数内的局部静态变量初始化是线程安全的,编译器会自动插入类似call_once的逻辑。因此更现代的写法是直接返回局部静态引用,这背后标准库很可能就是用call_once或等价机制实现的。
class Config {
public:
static Config& instance() {
static Config inst; // C++11起线程安全
return inst;
}
private:
Config() = default;
};
这种写法最简洁,也最不容易出错。但如果你的初始化函数需要捕获外部参数、或者想显式控制flag位置(例如作为类静态成员跨方法共享),手动使用call_once会更灵活。
四、call_once的底层原理与优势
从实现角度看,std::once_flag内部通常包含一个状态字和关联的系统级同步原语。第一次进入call_once的线程将状态置为进行中并运行函数;其他线程看到状态后自旋或等待条件变量,直到状态变为完成。它比用户态双重检查更安全,因为标准库保证了必要的获取-释放内存序(acquire-release),使得构造完成后的写入对等待线程可见。
相比全局互斥量保护整个getInstance方法,call_once只在首次初始化时产生同步开销,后续调用几乎无成本(仅一次原子状态读取)。它也避免了开发者误用内存屏障,降低了并发Bug概率。
五、实践中的注意事项
第一,once_flag不能拷贝或移动,必须声明为静态或动态分配且生命周期足够长。第二,如果初始化函数可能抛异常,call_once会允许其他线程重试,因此确保初始化要么成功要么程序终止,不要在里面写部分生效的逻辑。第三,在动态库场景中,不同动态库可能拥有独立的静态区,跨库共享单例时需将flag和实例放在同一导出符号中。
综合来看,使用call_once实现单例是C++多线程下兼顾安全与性能的首选方案,尤其适合需要延迟加载且构造较重的全局服务,如日志器、配置管理器、数据库连接池等。