☰
C++多线程std::call_once的使用
2026/10/7 15:50:50 网站建设 项目流程

前言

多线程程序里有一类初始化很尴尬:它只能做一次,但多个线程可能同时撞上来。最典型的例子是加载配置、建连接池、注册日志回调。用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抛出异常,标志不会被置位,下一个线程会重试。

本文以 C++17 为基准,把call_once的语义、同步保证、异常行为讲清楚,再和"函数内静态变量""mutex 加 bool""原子双检锁"三种方案做对比。

一、一次性初始化的三种写法

先看问题的原貌。假设有个全局的初始化函数initResource(),多个线程都要确保它跑过:

// 写法一:mutex 加 bool —— 正确,但每次都要抢锁 std::mutex g_mutex; bool g_inited = false; void ensureInit() { std::lock_guard<std::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_guard<std::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_once(glibc 内部用 futex 实现),后来的版本改成了基于原子变量加 futex 的自实现;MSVC STL 则把call_once委托给 Windows 平台的一次性初始化原语。这些差异会影响等待时的行为(阻塞还是短暂自旋、快路径读几个字节),但不影响可观察的语义。所以:只依赖标准写的保证,不要依赖某一家的实现方式。

三、实战:两个完整的可编译例子

例子一:只执行一次(基础语义)

// once_basic.cpp —— C++17 #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::vector<std::thread> pool; for (int i = 0; i < 4; ++i) { pool.emplace_back(worker, i); } for (auto& t : pool) { t.join(); } return 0; }

编译运行:

g++ -std=c++17 -pthread -Wall -Wextra -o once_basic once_basic.cpp

输出里"初始化由线程 X 完成"只出现一次,X 是哪一号线程每次运行都可能不同。多线程同时往std::cout做多次<<时行与行之间可能交错(std::cout本身不会数据竞争,但一次输出里的几个<<不是原子的),所以"开始干活"那几行顺序不定,这是演示程序的正常现象。

例子二:异常导致重试

// once_exception.cpp —— C++17 #include <atomic> #include <exception> #include <iostream> #include <mutex> #include <stdexcept> #include <thread> std::once_flag g_flag; std::atomic<int> 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 —— C++17 #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_ptr<Config> instance_; int port_; }; std::once_flag Config::initFlag_; // 类外定义(本翻译单元内) std::unique_ptr<Config> Config::instance_; int main() { std::cout << "port = " << Config::instance().port() << '\n'; return 0; }

更简单的等价写法(C++11 起局部静态变量的初始化就是线程安全的):

class Config { public: static Config& instance() { static Config inst; // 初始化只发生一次,且保证线程安全 return inst; } private: Config() = default; };

C++11 起标准明确规定:函数内的静态局部变量,其初始化在多线程竞争下只会执行一次,其他线程会等待初始化完成。如果你的场景就是一个函数内可见的单例,直接用这个写法即可,不需要call_once。call_once的价值在于:标志位可以是你自己选择生命周期的对象(比如某个类的成员),签名的自由度更大。

四、和其他方案的对比

方案完成后的调用开销线程安全异常处理适用场景
std::call_once一次原子读(实现相关),通常不需要加锁✅ 有同步保证异常不置位,下次重试一次性初始化,尤其是标志生命周期自定义时
函数内静态局部变量一次守卫变量检查✅ 有同步保证异常不置位,下次重试最简单的单例
std::mutex+bool每次都要加锁解锁✅ 但要注意每次都读锁内的值需要自己写初始化逻辑简单、调用频率低
原子双检锁(DCLP)一次 acquire 读✅ 前提是内存序写对需要自己写需要无锁快路径且不能用once_flag时

如果要自己实现双检锁,内存序必须写对:

#include <atomic> #include <mutex> struct Conn { int fd = -1; }; std::atomic<Conn*> g_conn{nullptr}; std::mutex g_mutex; Conn* getConn() { Conn* p = g_conn.load(std::memory_order_acquire); // 快路径:只读一次,加 acquire if (p == nullptr) { std::lock_guard<std::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_once

std::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; }); // ❌ 不保证由 id==0 的线程执行 }

标准不规定由哪个线程执行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不能用于线程同步
典型替代函数内静态局部变量(C++11 起同样线程安全,且更简洁)


用一句话选型:如果只是"函数级单例",直接用函数内静态局部变量;如果需要自己掌控标志的生命周期,或者初始化函数要接受参数、要处理异常,就用std::call_once。无论用哪种,都不要退回"裸bool双检锁"——那是数据竞争,是 UB,而且它的失败方式(偶尔读到半成品数据)恰恰是最难查的那一类。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询