C++的std::call_once在单例模式初始化时比加锁更高效吗? (性能实测)

2次阅读

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

C++的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 还是锁,而是想清楚:初始化失败要不要重试?要不要记录日志?多个单例之间有没有依赖?这些决策比微秒级性能差异重要得多。

text=ZqhQzanResources