前言多线程程序里有一类初始化很尴尬它只能做一次但多个线程可能同时撞上来。最典型的例子是加载配置、建连接池、注册日志回调。用std::mutex加一个bool标志也能做代价是每一次调用都要先抢锁——而初始化完成之后那 99.99% 的调用只是想确认一下已经好了而已。std::call_once就是为这种一次性初始化设计的它保证传给它的函数只被完整执行一次并且完成之后的后续调用能走一条轻量路径。它和std::once_flag一起定义在mutex里函数签名是template class Callable, class... Args void call_once(std::once_flag flag, Callable f, Args... args);初学者对它的误解主要有两个。第一个是以为它只是加了锁的 if 判断因此忽略了它最值钱的部分——同步语义执行初始化的那个线程写的所有数据对之后每一个调用call_once的线程都是可见的这一点靠裸bool是拿不到的。第二个是以为call_once只管调用次数不管异常——实际上如果f抛出异常标志不会被置位下一个线程会重试。本文以 C17 为基准把call_once的语义、同步保证、异常行为讲清楚再和函数内静态变量mutex 加 bool原子双检锁三种方案做对比。一、一次性初始化的三种写法先看问题的原貌。假设有个全局的初始化函数initResource()多个线程都要确保它跑过// 写法一mutex 加 bool —— 正确但每次都要抢锁 std::mutex g_mutex; bool g_inited false; void ensureInit() { std::lock_guardstd::mutex lock(g_mutex); if (!g_inited) { initResource(); g_inited true; } }这段代码没有数据竞争是对的。问题在于即使是第一万次调用它照样要加锁解锁。// 写法二裸 bool 双检 —— 有数据竞争是 UB bool g_inited2 false; void ensureInitBad() { if (g_inited2) return; // 锁外读 std::lock_guardstd::mutex lock(g_mutex); if (!g_inited2) { initResource(); g_inited2 true; // 锁内写 } }这是数据竞争data race属于未定义行为标准不保证任何结果。锁外的读和锁内的写没有 happens-before 关系编译器完全可以把g_inited2缓存在寄存器里、把initResource()的写重排到g_inited2 true之后。症状是偶尔拿到未初始化的数据而且换台机器、换个优化级别就可能复现不了。// 写法三call_once —— 一次写得对 std::once_flag g_flag; void ensureInit() { std::call_once(g_flag, initResource); }第三种写法既没有每次加锁的开销又拿到了语言规定的同步保证。下面的章节展开讲为什么。二、语义细节谁执行、失败了怎么办、同步保证是什么谁执行不由你决定多个线程同时调用带同一个flag的call_once时标准不规定哪一个线程执行f。它可能是第一个到达的也可能是调度器随便挑的一个。所以任何初始化一定在启动线程里做的假设都是错的。如果初始化必须在特定线程完成例如必须持有某个 GUI 资源就不要用call_once改用显式的线程间协调。异常标志不置位下一次重试如果f执行过程中抛出异常call_once会把这个异常传播给调用者并且这一轮不算数——flag不会被标记为已完成下一个调用call_once的线程会重新尝试执行f。如果每次f都抛那就每次都重试永远不会完成。这条规则既是保护也是陷阱它保证了绝不留下半成品状态但也意味着如果初始化函数里吞掉异常比如自己 try-catch 了却不报告你可能会认为初始化成功了实际上什么都没做。所以要么让异常传播出去让调用方感知要么在函数内部把失败显式记录下来。同步保证标准对call_once的同步保证是主动执行f的那个调用的完成与所有被动调用等待并返回的的返回之间建立 synchronizes-with 关系。翻译成人话执行f的线程在f里写的所有内存对任何在call_once返回后继续执行的线程都必然可见那些在f执行期间到达的线程会被阻塞直到f完成并且它们看到的是完整的、初始化之后的值。这正是裸bool版本给不了的东西。裸bool即使侥幸看起来能跑也只是因为运气好遇上了 x86 的强内存模型标准的保证是零。顺便明确一句volatile不能用来做这件事。volatile不提供原子性也不建立 happens-before 关系它只告诉编译器这个变量可能被外部改变不要优化掉对它的访问。拿volatile bool当同步标志是错的。实现细节别依赖具体怎么实现标准只规定语义不规定实现。以 libstdc 在 Linux 上的实现为例早期版本是把std::call_once转发到 POSIX 的pthread_onceglibc 内部用 futex 实现后来的版本改成了基于原子变量加 futex 的自实现MSVC STL 则把call_once委托给 Windows 平台的一次性初始化原语。这些差异会影响等待时的行为阻塞还是短暂自旋、快路径读几个字节但不影响可观察的语义。所以只依赖标准写的保证不要依赖某一家的实现方式。三、实战两个完整的可编译例子例子一只执行一次基础语义// once_basic.cpp —— C17 #include iostream #include mutex #include thread #include vector std::once_flag g_flag; void worker(int id) { std::call_once(g_flag, [id] { // 这段代码在所有线程里加起来只会执行一次 std::cout 初始化由线程 id 完成\n; }); std::cout 线程 id 开始干活\n; } int main() { std::vectorstd::thread pool; for (int i 0; i 4; i) { pool.emplace_back(worker, i); } for (auto t : pool) { t.join(); } return 0; }编译运行g -stdc17 -pthread -Wall -Wextra -o once_basic once_basic.cpp输出里初始化由线程 X 完成只出现一次X 是哪一号线程每次运行都可能不同。多线程同时往std::cout做多次时行与行之间可能交错std::cout本身不会数据竞争但一次输出里的几个不是原子的所以开始干活那几行顺序不定这是演示程序的正常现象。例子二异常导致重试// once_exception.cpp —— C17 #include atomic #include exception #include iostream #include mutex #include stdexcept #include thread std::once_flag g_flag; std::atomicint g_attempts{0}; void initResource() { const int n g_attempts; // 原子自增多线程下计数可靠 std::cout 第 n 次尝试初始化\n; if (n 1) { throw std::runtime_error(第一次初始化故意失败); } std::cout 初始化成功\n; } int main() { std::thread t1([] { try { std::call_once(g_flag, initResource); } catch (const std::exception e) { std::cout t1 捕获异常: e.what() \n; } }); t1.join(); std::thread t2([] { try { std::call_once(g_flag, initResource); } catch (const std::exception e) { std::cout t2 捕获异常: e.what() \n; } }); t2.join(); return 0; }输出这个例子里两个线程是串行执行的所以顺序确定第 1 次尝试初始化 t1 捕获异常: 第一次初始化故意失败 第 2 次尝试初始化 初始化成功t1那次失败没有把g_flag置位所以t2又完整地执行了一遍initResource并且成功。这就是异常不置位的可观察证据。例子三单例的两种写法// once_singleton.cpp —— C17 #include iostream #include memory #include mutex class Config { public: static Config instance() { std::call_once(initFlag_, [] { instance_.reset(new Config()); }); return *instance_; } int port() const noexcept { return port_; } private: Config() : port_(8080) {} static std::once_flag initFlag_; static std::unique_ptrConfig instance_; int port_; }; std::once_flag Config::initFlag_; // 类外定义本翻译单元内 std::unique_ptrConfig Config::instance_; int main() { std::cout port Config::instance().port() \n; return 0; }更简单的等价写法C11 起局部静态变量的初始化就是线程安全的class Config { public: static Config instance() { static Config inst; // 初始化只发生一次且保证线程安全 return inst; } private: Config() default; };C11 起标准明确规定函数内的静态局部变量其初始化在多线程竞争下只会执行一次其他线程会等待初始化完成。如果你的场景就是一个函数内可见的单例直接用这个写法即可不需要call_once。call_once的价值在于标志位可以是你自己选择生命周期的对象比如某个类的成员签名的自由度更大。四、和其他方案的对比方案完成后的调用开销线程安全异常处理适用场景std::call_once一次原子读实现相关通常不需要加锁✅ 有同步保证异常不置位下次重试一次性初始化尤其是标志生命周期自定义时函数内静态局部变量一次守卫变量检查✅ 有同步保证异常不置位下次重试最简单的单例std::mutexbool每次都要加锁解锁✅ 但要注意每次都读锁内的值需要自己写初始化逻辑简单、调用频率低原子双检锁DCLP一次 acquire 读✅ 前提是内存序写对需要自己写需要无锁快路径且不能用once_flag时如果要自己实现双检锁内存序必须写对#include atomic #include mutex struct Conn { int fd -1; }; std::atomicConn* g_conn{nullptr}; std::mutex g_mutex; Conn* getConn() { Conn* p g_conn.load(std::memory_order_acquire); // 快路径只读一次加 acquire if (p nullptr) { std::lock_guardstd::mutex lock(g_mutex); p g_conn.load(std::memory_order_relaxed); // 持锁后重读relaxed 足够 if (p nullptr) { p new Conn{}; g_conn.store(p, std::memory_order_release); // 发布保证 Conn 的写先于指针可见 } } return p; }这里的两个内存序都不能省store(..., release)保证new Conn{}对对象内部成员的写在指针可见之前就已完成load(..., acquire)保证读到该指针之后后续对对象的读不会被重排到这次读之前。如果两边都用memory_order_relaxed就退化成有数据竞争的双检锁读到的可能是一个指针已经可见、但对象内部成员还没写完的半成品。反之用默认的memory_order_seq_cst是正确但代价略高的写法。五种内存序的差别relaxed只保证原子性和修改顺序不建立任何同步acquire阻止它之后的读写被上移release阻止它之前的读写被下移acq_rel两者兼具用于读改写操作seq_cst在前者基础上再要求所有线程看到统一的操作总序。不加锁的代码里唯一能建立 happens-before 的就是 acquire/release 配对这一点和call_once内部的实现原理是一样的。常见坑点1. 用volatile或裸bool当同步标志volatile bool g_inited false; // ❌ volatile 不提供原子性也不建立 happens-before if (!g_inited) { initResource(); g_inited true; } // 数据竞争UB std::once_flag g_flag; // ✅ std::call_once(g_flag, initResource);2. 在传给call_once的函数里再次对同一个 flag 调用call_oncestd::once_flag g_flag; void init() { std::call_once(g_flag, [] { std::call_once(g_flag, [] {}); }); // ❌ 自等待死锁 }同一个线程既在执行、又在等待执行完成标准没有为这种重入定义有用的行为实现上必然卡死。3. 以为once_flag可以重置或拷贝std::once_flag a, b a; // ❌ 拷贝构造被删除 a std::once_flag{}; // ❌ 赋值被删除once_flag 也没有 reset()需要可重置的一次性标志说明你要的不是call_once。4. 以为call_once管析构static Config instance() { std::call_once(initFlag_, [] { instance_.reset(new Config()); }); return *instance_; // ❌ call_once 只保证构造一次销毁时机由你负责 }call_once的保证只覆盖构造。对象什么时候销毁、能不能重复构造call_once一概不管。用函数内静态变量则是由运行期在程序退出时统一析构的反而更省心。5. 依赖是我这个线程执行的初始化void worker(int id) { std::call_once(g_flag, [] { myId id; }); // ❌ 不保证由 id0 的线程执行 }标准不规定由哪个线程执行f任何依赖执行者身份的逻辑都是不可移植的。6. 同一个 flag 配不同的参数std::call_once(g_flag, setup, a); // 第一次生效 std::call_once(g_flag, setup, b); // ❌ 什么都不会发生b 被静默丢弃call_once只认 flag不比较参数。参数不同但 flag 相同第二次调用就是一个空操作。这个静默丢弃很难排查。7. 初始化函数吞掉异常导致以为初始化好了void init() { try { connectToServer(); } catch (...) { // ❌ 异常被吞call_once 认为初始化成功flag 被置位 } }call_once判断成功与否的唯一依据是f是否正常返回。吞掉异常等于告诉它我成功了。8. 忘记把once_flag定义在合适的翻译单元里class Foo { static std::once_flag flag_; // 类内只声明 }; // 忘记写 std::once_flag Foo::flag_; —— 链接错误 // ✅ 在唯一的一个 .cpp 里定义一次 std::once_flag Foo::flag_;总结语义点结论执行次数同一个flag对应的函数只完整执行一次执行者标准不规定是哪个线程不能依赖同步保证f的完成 synchronizes-with 所有被动调用的返回之前写入的内存全部可见异常行为f抛出异常时flag不置位下一个线程会重试once_flag的特性定义在mutex不可拷贝、不可移动、没有reset()生命周期flag必须存活到所有调用结束call_once不管对象析构与volatile的关系无关。volatile不能用于线程同步典型替代函数内静态局部变量C11 起同样线程安全且更简洁用一句话选型如果只是函数级单例直接用函数内静态局部变量如果需要自己掌控标志的生命周期或者初始化函数要接受参数、要处理异常就用std::call_once。无论用哪种都不要退回裸bool双检锁——那是数据竞争是 UB而且它的失败方式偶尔读到半成品数据恰恰是最难查的那一类。
阅读完成 · 觉得有帮助?