C++中多线程的执行顺序如你预期吗

发布时间:2026/10/20 3:31:12

C++中多线程的执行顺序如你预期吗 前言很多人在第一次写多线程程序时会默认「先创建的线程先执行」「join的顺序就是线程运行完成的顺序」甚至用一次运行的控制台输出反推线程的调度规律。这些预期在标准层面都没有依据。C 标准不规定线程何时开始运行、运行多久、在哪个 CPU 核心上运行也不规定多个线程之间指令的交替顺序。标准只规定一件事在满足同步关系的前提下一个线程的哪些写操作对另一个线程可见。另一个常见误解是把「输出顺序」当成「执行顺序」。即使两行输出没有交错也只能说明这两次operator调用在时间上有先后不能说明两个线程里其它语句的先后更不能说明下一次运行还是同样的顺序。把一次运行的结果当成规律是并发程序最难排查的错误来源之一。本文从标准的顺序关系讲起说明为什么线程顺序不可预期然后给出让顺序变得可预期的几种手段互斥量、条件变量、原子操作、std::promise与std::future以及 C20 的std::latch与std::barrier。最后用一个「按 A、B、C 依次输出」的例子把同步逻辑写完整。代码基准为 C17。一、创建顺序、调度顺序、可见顺序是三件事std::thread的构造函数返回时新线程已经可以开始执行但「可以执行」不等于「跑完第一条语句」。操作系统调度器决定哪个线程在哪个时刻占用 CPUC 标准把这一层完全交给实现。因此下面三件事互相独立概念由谁决定标准是否保证创建顺序调用std::thread构造函数的先后只保证构造的先后不保证执行先后调度顺序操作系统调度器不保证可见顺序happens-before 关系有同步时才有保证看一个最容易误导人的例子// 适用于 C17#include iostream#include threadint main() {std::thread a([]{ std::cout A\n; });std::thread b([]{ std::cout B\n; });a.join();b.join();}这段程序可能输出 A 在前也可能 B 在前。a.join()写在b.join()前面只表示主线程先等待a结束b完全可能比a更早跑完。join的语义是「阻塞直到该线程结束」不是「启动该线程」更不是「让该线程先跑」。还有一点常被忽略std::cout的每次格式化输出在标准层面不会产生数据竞争但多个调用之间可能交错。也就是说一个线程写value 、另一个线程写42最终屏幕上完全可能出现两段文字混在一起的情况。想保证一整行输出不被插入需要自己加锁。二、真正决定「谁先看到谁」的是 happens-before标准里的顺序关系可以粗略分成三层sequenced-before先后 sequenced同一个线程内语句或表达式的先后。这是最直观的一层。synchronizes-with同步于一个线程的释放操作与另一个线程的获取操作配对例如互斥量的unlock与随后的lock或原子变量的 release 写与 acquire 读。inter-thread happens-before线程间先于前两者的传递闭包。只要两个线程之间不存在 happens-before 关系而它们又访问了同一个非原子对象、其中至少一个是写就构成数据竞争data race。数据竞争在 C 里是未定义行为undefined behavior, UB——不是「结果可能不对」这么轻而是编译器可以做任何假设包括把整个循环优化掉。这就是为什么「加个volatile就好了」是错的。volatile只告诉编译器这个对象可能被外部改变禁止某些优化它不建立任何线程间的同步关系也不能替代std::atomic或互斥量。标准把volatile的用途限定在内存映射 I/O 等特殊场景。反过来一旦建立了 happens-before前一个线程在同步点之前的所有写入对后一个线程在同步点之后都是可见的。这就是互斥量除了「互斥」之外的第二个作用它同时是一条内存屏障。三、把顺序写成契约四种常用手段3.1 互斥量保护共享状态std::mutex加std::lock_guard是最简单的组合。lock_guard在构造时加锁、析构时解锁异常路径也不会漏掉解锁。需要中途解锁或在条件变量上等待时用std::unique_lock。多个互斥量要一起获取时优先用 C17 的std::scoped_lock它内部采用避免死锁的算法次选std::lock配合std::unique_lock的std::defer_lock。不要手写「先锁 A 再锁 B」的嵌套两个线程以相反顺序加锁就会死锁。3.2 条件变量表达「等到某条件成立」std::condition_variable必须搭配谓词使用// 适用于 C17std::unique_lockstd::mutex lock(mtx);cv.wait(lock, []{ return ready; });带谓词的wait等价于一个循环先检查谓词成立就返回不成立就原子地释放锁并阻塞被唤醒后重新加锁再检查。这个循环是必须的因为条件变量存在虚假唤醒spurious wakeup——即使没有人调用notify等待也可能返回。不带谓词的wait把「是否该继续」的判断留给调用者一旦忘记写循环程序就会在条件不成立时继续往下跑。还要注意等待前必须持有锁notify_one/notify_all可以在持锁或不持锁时调用但通常建议在释放锁之后通知避免被唤醒的线程立刻又阻塞在锁上。3.3 原子变量与内存序std::atomicT保证对这个变量本身的读写是原子的但不会自动保护与它相关的其它数据。典型的发布模式是// 适用于 C17#include atomicstd::atomicbool ready{false};int payload 0;// 线程 1payload 42;ready.store(true, std::memory_order_release);// 线程 2while (!ready.load(std::memory_order_acquire)) { /* 自旋 */ }// 此处读 payload 是安全的能看到 42release保证它之前的写入不会被重排到 store 之后acquire保证它之后的读取不会被重排到 load 之前两者配对后payload的写入对线程 2 可见。如果两边都用默认的memory_order_seq_cst同样正确而且更容易推理。放松内存序属于性能优化手段不确定时不要用——写错就是 UB而不是「偶尔读到旧值」。3.4 一次性事件promise/future 与 latch如果只是「等另一个线程算完一个值」用std::promiseT和std::futureT比手写条件变量更清晰// 适用于 C17#include future#include threadstd::promiseint p;std::futureint f p.get_future();std::thread t([p]{p.set_value(42); // 恰好设置一次});int value f.get(); // 阻塞直到值就绪t.join();set_value只能调用一次重复调用抛std::future_error。future的get()也只能调用一次第二次调用是无效状态。需要多个线程共同等待一个「发令枪」时C20 提供了std::latch一次性倒数和std::barrier可复用的栅栏它们需要 C20 及以上且各标准库实现的支持时间不同。完整示例按 A、B、C 的顺序输出下面用互斥量加条件变量把「三个线程按固定顺序各输出一次」写成显式的契约。关键是一个受锁保护的turn变量每个线程只在自己的编号轮到时才继续。// 适用于 C17#include condition_variable#include iostream#include mutex#include threadstd::mutex mtx;std::condition_variable cv;int turn 0; // 0 表示轮到 A1 表示 B2 表示 Cvoid worker(int id, const char* name) {std::unique_lockstd::mutex lock(mtx);cv.wait(lock, [id]{ return turn id; });std::cout name \n;turn;lock.unlock(); // 先释放锁再通知cv.notify_all();}int main() {std::thread ta(worker, 0, A);std::thread tb(worker, 1, B);std::thread tc(worker, 2, C);ta.join();tb.join();tc.join();}推演一下同步逻辑三个线程启动后都先加锁然后进入wait。假设 C 先抢到锁谓词turn 2为假wait释放锁并阻塞B 抢到锁turn 1为假同样阻塞A 抢到锁turn 0成立输出 A把turn改成 1解锁后notify_all。B 和 C 被唤醒重新竞争锁只有 B 的谓词成立输出 B。依此类推。这里有三个容易写错的点。第一turn的读写必须在锁内否则就是数据竞争。第二turn之后必须先解锁再通知否则被唤醒的线程醒来后仍拿不到锁白白多一次阻塞。第三谓词捕获的是id而不是引用避免线程参数的生命周期问题。如果去掉条件变量、改成while (turn ! id) {}的空转程序在逻辑上也可能「碰巧」正确但这是忙等待它持续占用 CPU且在turn不是原子变量时仍然是数据竞争。更隐蔽的写法是while (turn ! id) std::this_thread::yield();同样没有同步语义。常见坑点❌ 认为std::thread的构造顺序就是执行顺序。✅ 把线程启动看作「提交任务」顺序只由同步关系决定需要顺序就显式同步。❌ 用std::this_thread::sleep_for来「保证」另一个线程先跑完。✅ 用条件变量、future或原子标志等待确定的事件。睡眠时间再长也只是概率机器负载一变就失效。❌ 条件变量写成if (!ready) cv.wait(lock);。✅ 写成cv.wait(lock, []{ return ready; });用循环语义抵御虚假唤醒。❌ 在多个线程里无保护地读写同一个int计数器认为「加一只是条指令」。✅ 用std::atomicint的fetch_add或者用互斥量保护。非原子的并发读写是数据竞争属于 UB。❌ 用volatile int flag做线程间的标志位。✅ 用std::atomicbool配合 acquire/release或直接用互斥量。volatile不提供原子性和同步。❌ 两个线程以相反顺序嵌套加锁。✅ 用std::scoped_lock(a, b)C17一次性获取多个锁或统一加锁顺序。❌ 认为std::cout的一次链一定整体输出、不会与其他线程交错。✅ 需要整行原子输出时用互斥量包住整条输出语句或先拼成std::string再一次写出。❌ 线程对象析构前既没有join也没有detach。✅ 每个std::thread都要么join要么detach否则析构时调用std::terminate。更推荐用 RAII 包装或 C20 的std::jthread。总结你的预期标准的实际规定正确做法先创建先执行不保证调度顺序用同步原语建立顺序join让线程先跑join只是等待结束需要「先做」就发信号输出顺序等于执行顺序输出只反映调用时刻不拿输出当同步证据sleep可以替代同步睡眠不建立 happens-before条件变量或 futurevolatile可以同步不提供原子性与可见性std::atomic或互斥量原子变量保护所有相关数据只保护它自己用锁保护整组不变量多线程的执行顺序从来不是「你预期怎样就怎样」而是「你同步到哪里就保证到哪里」。把顺序当成需要显式建立的契约而不是调度器碰巧给出的结果程序才能在换机器、换负载、换编译器之后仍然正确。需要提醒的是本文所有代码都只按标准语义推演没有在本机编译或运行不同标准库实现对std::cout的缓冲、对条件变量唤醒顺序的处理可能不同但上述同步逻辑本身不依赖这些实现细节。
延伸阅读

更多相关文章

2026/10/20 3:31:12

用OCR与规则引擎构建报表信息抽取助手

“rea”这个代号,不是某个英文单词的随手缩写,而是我手头一个内部小工具的名字,全称是 Report Extraction Assistant,翻译过来就是报表信息抽取助手。最早写它,是因为有段时间我被迫处理大量来自不同系统的导出报告、电…

2026/10/20 3:26:11

NAND Flash芯片测试程序全解析:坏块扫描、位翻转与ECC校验

简介:面向NAND Flash驱动开发与验证场景的测试程序包,适合嵌入式开发者、驱动调试人员及存储技术学习者使用。程序针对驱动与硬件交互中的关键问题,覆盖基础读写一致性校验、坏块检测与标记、ECC纠错验证、损耗均衡检查、页与块管理、读写性能…

2026/10/20 4:41:17

AI代理协同攻击蜜罐事件解读:越界风险与防护实践

安全圈最近有个数字被反复提起:约 700 个 AI 代理在几天内持续参与了对一批蜜罐集群的协同攻击。我是在后台日志里先注意到异常的——大量来自不同来源 IP 的扫描请求,行为模式却出奇一致,不是传统扫描器那种死板的规律重放,而是会…

2026/10/20 4:41:17

魔改Informer实现滚动长期预测:时间序列科研项目实战

简介:这份资源面向时间序列预测方向的研究者与毕业设计开发者,针对官方Informer模型仅支持固定长度预测、难以满足论文实验需求的痛点,在原始代码基础上扩展了滚动长期预测能力:首次预测未来24个时间点后自动回填结果并继续预测下…

2026/10/20 4:41:17

基于卷积神经网络的肺炎诊断系统设计与实现全攻略

又到毕设选题季,每年这个时候后台都会收到不少私信,问的无非就那么几个问题:题目好不好做、数据从哪来、工作量够不够、答辩能不能过。今天我想专门聊聊一个我认为性价比很高的方向——基于卷积神经网络(CNN)模型的肺炎…

2026/10/20 4:41:17

Kafka 2.8.0 Java客户端生产者与消费者深度实践指南

1. 项目概述:为什么一个“简单实现”值得花时间深挖?Kafka集群(2.8.0版本)之四:Java简单实现生产者和消费者——这个标题乍看平平无奇,像是教科书里一页翻过就忘的示例代码。但在我过去三年带过的十几个Kaf…

2026/10/20 4:41:17

HDFS存储成本优化:主流压缩算法对比与选型实践

我们平时维护大数据集群,最绕不开的一件事就是存储成本。尤其是HDFS上堆了几百TB甚至PB级的数据,每次看到告警说“磁盘空间不足”,第一反应都是赶紧清理临时目录,但治标不治本。真正能长期见效的手段之一,就是在数据落…

2026/10/20 4:36:17

个人词典误伤语境?Entune语境感知分层纠错实践

做听写类产品久了会发现一个反直觉的现象:用户亲手维护的个人词典,往往是误伤率最高的出错来源。听起来很矛盾。语音识别遇到专有名词和生僻词时不听话,于是产品给你一个“个人词典”,把“云界”“凯格宁”这种词加进去&#xff0…

2026/10/19 18:06:35

金融AI落地指南:从调研报告到技术选型与工程实践

简介:这份PDF报告聚焦2025年金融服务业中国AI现状与趋势,面向金融科技从业者、AI战略规划人员及关注行业数字化转型的研究者,帮助读者把握生成式AI在银行、支付、交易等场景中的落地路径与投资方向。资源包内含1个PDF文件,大小约1…

2026/10/20 1:05:37

单片机C运行时从启动到多任务:中断安全与共享资源冲突

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/20 1:05:37

GCC编译四步详解:预处理、编译、汇编、链接实战

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/20 1:05:37

FIW线是什么?FEP注塑线与普通氟塑料线的区别及应用指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

还想了解更多?直接咨询顾问

免费诊断 + 免费方案 + 透明报价。

全国咨询热线400-8866-253
免费获取方案
☎咨询二维码 ☎ ↑