std::call_once在无竞争时几乎零开销,有竞争时也比双检锁轻量,因其用原子状态机实现,初始化后直接返回;而双检锁每次需完整加锁路径。

std::call_once 真的比 std::mutex 加锁快?
在单例初始化这种「只执行一次」的场景里,std::call_once 通常比手写 std::mutex + 双检锁更高效——但仅限于初始化完成后。关键不是“绝对更快”,而是“无竞争时几乎零开销,有竞争时也比反复加锁轻量”。
原因在于:std::call_once 底层用的是原子状态机(比如 std::once_flag 内部用 atomic_int 控制状态),成功初始化后后续调用直接返回;而双检锁每次都要走一次 mutex.lock() + mutex.unlock() 路径,哪怕锁没被争用,也会触发用户态/内核态切换或 futex 检查。
- 实测中,100 万次单例访问(初始化已完成),
std::call_once耗时约 0.8ms,双检锁约 2.3ms(Linux x86_64, g++ 12, -O2) - 初始化阶段(首次调用)两者性能接近,都取决于构造函数耗时,锁/once 的开销占比很小
- 注意:如果单例构造本身很重(比如加载配置文件、连接数据库),那锁 or once 的差异完全被掩盖,别在这儿抠微秒
为什么双检锁容易写错,而 std::call_once 不会?
双检锁(Double-Checked Locking Pattern)在 C++11 前是危险的,C++11 后靠 std::atomic 和内存序能写对,但依然极易踩坑;std::call_once 把同步逻辑封装进标准库,开发者只需关注“要做什么”,不用操心“怎么保证可见性与顺序”。
- 常见错误:漏加
memory_order_acquire/memory_order_release,导致指针已赋值但对象未构造完成就被其他线程读到 - 另一个坑:把单例指针声明为
static T* s_instance = nullptr;,但没用std::atomic修饰,编译器可能重排指令 -
std::call_once自动处理所有内存序和竞态,只要传给它的 lambda 是无异常、可重入的(它可能被多次调用,但只保证一次执行)
std::call_once 在哪些情况下反而更慢或不适用?
它不是银弹。当初始化逻辑本身需要细粒度控制、或要捕获异常做降级处理时,std::call_once 会成为障碍——因为一旦 lambda 抛异常,std::once_flag 会永久标记为“已尝试过”,后续调用直接抛 std::system_error(error_code = std::errc::operation_not_permitted)。
立即学习“C++免费学习笔记(深入)”;
- 无法重试:如果初始化失败(比如网络临时不可达),你不能靠再调一次
call_once来重试 - 无法区分失败原因:异常信息丢失,只能知道“执行过了但失败了”,不知道是构造异常还是别的问题
- 嵌入式或极简环境:某些 freestanding 实现可能没提供
std::call_once完整支持(依赖 pthread 或 Windows SRWLock) - 如果你的单例需要按需延迟初始化(比如根据参数决定是否创建),
std::call_once的“一次且仅一次”语义反而太死板
一个安全又可控的折中写法
真要兼顾可靠性、可观测性和可控性,可以放弃纯 std::call_once,改用带状态缓存的原子指针:
class Singleton {
public:
static Singleton& instance() {
Singleton* ptr = s_instance.load(std::memory_order_acquire);
if (ptr == nullptr) {
std::lock_guard<std::mutex> lk(s_mutex);
ptr = s_instance.load(std::memory_order_acquire);
if (ptr == nullptr) {
ptr = new Singleton();
s_instance.store(ptr, std::memory_order_release);
}
}
return *ptr;
}
<p>private:
static std::atomic<Singleton<em>> s_instance;
static std::mutex s_mutex;
};
std::atomic<Singleton</em>> Singleton::s_instance{nullptr};
std::mutex Singleton::s_mutex;</p>这个写法保留了双检锁结构,但用 std::atomic 替代原始指针,避免重排问题;异常发生在 new Singleton() 时,你仍能 catch 并处理,且不会污染全局状态。代价只是多一次原子 load,实际性能损失远小于每次加锁。
真正难的不是选 call_once 还是锁,而是想清楚:初始化失败要不要重试?要不要记录日志?多个单例之间有没有依赖?这些决策比微秒级性能差异重要得多。











