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

前言


很多人在第一次写多线程程序时,会默认「先创建的线程先执行」「join的顺序就是线程运行完成的顺序」,甚至用一次运行的控制台输出反推线程的调度规律。这些预期在标准层面都没有依据。C++ 标准不规定线程何时开始运行、运行多久、在哪个 CPU 核心上运行,也不规定多个线程之间指令的交替顺序。标准只规定一件事:在满足同步关系的前提下,一个线程的哪些写操作对另一个线程可见。


另一个常见误解是把「输出顺序」当成「执行顺序」。即使两行输出没有交错,也只能说明这两次operator<<调用在时间上有先后,不能说明两个线程里其它语句的先后,更不能说明下一次运行还是同样的顺序。把一次运行的结果当成规律,是并发程序最难排查的错误来源之一。


本文从标准的顺序关系讲起,说明为什么线程顺序不可预期,然后给出让顺序变得可预期的几种手段:互斥量、条件变量、原子操作、std::promise与std::future,以及 C++20 的std::latch与std::barrier。最后用一个「按 A、B、C 依次输出」的例子把同步逻辑写完整。代码基准为 C++17。


一、创建顺序、调度顺序、可见顺序是三件事


std::thread的构造函数返回时,新线程已经可以开始执行,但「可以执行」不等于「跑完第一条语句」。操作系统调度器决定哪个线程在哪个时刻占用 CPU,C++ 标准把这一层完全交给实现。因此下面三件事互相独立:




概念由谁决定标准是否保证



创建顺序调用std::thread构造函数的先后只保证构造的先后,不保证执行先后

调度顺序操作系统调度器不保证

可见顺序happens-before 关系有同步时才有保证



看一个最容易误导人的例子:


// 适用于 C++17
#include <iostream>
#include <thread>

int 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


标准里的顺序关系可以粗略分成三层:



  1. sequenced-before(先后 sequenced):同一个线程内,语句或表达式的先后。这是最直观的一层。

  2. synchronizes-with(同步于):一个线程的释放操作与另一个线程的获取操作配对,例如互斥量的unlock与随后的lock,或原子变量的 release 写与 acquire 读。

  3. 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。


多个互斥量要一起获取时,优先用 C++17 的std::scoped_lock,它内部采用避免死锁的算法;次选std::lock配合std::unique_lock的std::defer_lock。不要手写「先锁 A 再锁 B」的嵌套,两个线程以相反顺序加锁就会死锁。


3.2 条件变量表达「等到某条件成立」


std::condition_variable必须搭配谓词使用:


// 适用于 C++17
std::unique_lock<std::mutex> lock(mtx);
cv.wait(lock, [&]{ return ready; });

带谓词的wait等价于一个循环:先检查谓词,成立就返回;不成立就原子地释放锁并阻塞,被唤醒后重新加锁再检查。这个循环是必须的,因为条件变量存在虚假唤醒(spurious wakeup)——即使没有人调用notify,等待也可能返回。不带谓词的wait把「是否该继续」的判断留给调用者,一旦忘记写循环,程序就会在条件不成立时继续往下跑。


还要注意:等待前必须持有锁;notify_one/notify_all可以在持锁或不持锁时调用,但通常建议在释放锁之后通知,避免被唤醒的线程立刻又阻塞在锁上。


3.3 原子变量与内存序


std::atomic<T>保证对这个变量本身的读写是原子的,但不会自动保护与它相关的其它数据。典型的发布模式是:


// 适用于 C++17
#include <atomic>

std::atomic<bool> ready{false};
int payload = 0;

// 线程 1
payload = 42;
ready.store(true, std::memory_order_release);

// 线程 2
while (!ready.load(std::memory_order_acquire)) { /* 自旋 */ }
// 此处读 payload 是安全的,能看到 42

release保证它之前的写入不会被重排到 store 之后,acquire保证它之后的读取不会被重排到 load 之前,两者配对后payload的写入对线程 2 可见。如果两边都用默认的memory_order_seq_cst,同样正确,而且更容易推理。放松内存序属于性能优化手段,不确定时不要用——写错就是 UB,而不是「偶尔读到旧值」。


3.4 一次性事件:promise/future 与 latch


如果只是「等另一个线程算完一个值」,用std::promise<T>和std::future<T>比手写条件变量更清晰:


// 适用于 C++17
#include <future>
#include <thread>

std::promise<int> p;
std::future<int> 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()也只能调用一次,第二次调用是无效状态。需要多个线程共同等待一个「发令枪」时,C++20 提供了std::latch(一次性倒数)和std::barrier(可复用的栅栏),它们需要 C++20 及以上,且各标准库实现的支持时间不同。


完整示例:按 A、B、C 的顺序输出


下面用互斥量加条件变量,把「三个线程按固定顺序各输出一次」写成显式的契约。关键是一个受锁保护的turn变量:每个线程只在自己的编号轮到时才继续。


// 适用于 C++17
#include <condition_variable>
#include <iostream>
#include <mutex>
#include <thread>

std::mutex mtx;
std::condition_variable cv;
int turn = 0; // 0 表示轮到 A,1 表示 B,2 表示 C

void worker(int id, const char* name) {
std::unique_lock<std::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::atomic<int>的fetch_add,或者用互斥量保护。非原子的并发读写是数据竞争,属于 UB。


❌ 用volatile int flag做线程间的标志位。


✅ 用std::atomic<bool>配合 acquire/release,或直接用互斥量。volatile不提供原子性和同步。


❌ 两个线程以相反顺序嵌套加锁。


✅ 用std::scoped_lock(a, b)(C++17)一次性获取多个锁,或统一加锁顺序。


❌ 认为std::cout的一次<<链一定整体输出、不会与其他线程交错。


✅ 需要整行原子输出时,用互斥量包住整条输出语句,或先拼成std::string再一次写出。


❌ 线程对象析构前既没有join也没有detach。


✅ 每个std::thread都要么join,要么detach;否则析构时调用std::terminate。更推荐用 RAII 包装或 C++20 的std::jthread。


总结




你的预期标准的实际规定正确做法



先创建先执行不保证调度顺序用同步原语建立顺序

join让线程先跑join只是等待结束需要「先做」就发信号

输出顺序等于执行顺序输出只反映调用时刻不拿输出当同步证据

sleep可以替代同步睡眠不建立 happens-before条件变量或 future

volatile可以同步不提供原子性与可见性std::atomic或互斥量

原子变量保护所有相关数据只保护它自己用锁保护整组不变量



多线程的执行顺序从来不是「你预期怎样就怎样」,而是「你同步到哪里,就保证到哪里」。把顺序当成需要显式建立的契约,而不是调度器碰巧给出的结果,程序才能在换机器、换负载、换编译器之后仍然正确。


需要提醒的是,本文所有代码都只按标准语义推演,没有在本机编译或运行;不同标准库实现对std::cout的缓冲、对条件变量唤醒顺序的处理可能不同,但上述同步逻辑本身不依赖这些实现细节。




相关新闻

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

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

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

2026/10/20 3:31:06 阅读更多 →
Linux下JACK启动失败排查指南:权限、实时调度与声卡占用

Linux下JACK启动失败排查指南:权限、实时调度与声卡占用

如果你在Linux平台上折腾过专业音频&#xff0c;大概率见过这条报错。Ubuntu 16.04上运行JACK时&#xff0c;控制台或QjackCtl直接甩出“Jack server failed to (re)start, try jack-diagnose or see Jack server log.”&#xff0c;很多第一次接触JACK的人会被这句提示搞得一头…

2026/10/20 3:30:06 阅读更多 →
从理论到落地:模型复现中的数据管线、损失曲线与量化部署实战

从理论到落地:模型复现中的数据管线、损失曲线与量化部署实战

最近在复盘自己的深度学习进阶笔记时&#xff0c;翻到了这套系列资料的第七篇第二部分&#xff0c;内容聚焦的是“从理论到落地的关键一跳”。说实话&#xff0c;这个阶段劝退了很多人——看理论时觉得什么都懂了&#xff0c;一上手跑模型就发现处处是坑。这篇笔记记录了我当时…

2026/10/20 3:30:06 阅读更多 →

最新新闻

技术人技能管理指南:从技能盘点、刻意练习到作品集构建

技术人技能管理指南:从技能盘点、刻意练习到作品集构建

1. 从“skills”这个词说起&#xff1a;为什么它突然成了硬通货“skills”这个词&#xff0c;放在五年前&#xff0c;可能只是简历底部一个不起眼的栏目。但现在你打开任何一个技术社区、招聘页面或者同行群聊&#xff0c;它出现的频率高得离谱。我自己的感受是&#xff0c;过去…

2026/10/20 4:25:39 阅读更多 →
从skills到能力体系:一线从业者的技能拆解与提升指南

从skills到能力体系:一线从业者的技能拆解与提升指南

1. 从“skills”这个词说起&#xff1a;为什么它突然成了高频热词最近一段时间&#xff0c;“skills”这个词在各类技术社区和职场讨论中出现的频率明显高了起来。不管是招聘信息里的“核心skills要求”&#xff0c;还是各种项目复盘里提到的“这个项目真正锻炼到的是什么skill…

2026/10/20 4:25:39 阅读更多 →
Skills能力模型拆解:从硬软技能到可迁移的T型与π型组合

Skills能力模型拆解:从硬软技能到可迁移的T型与π型组合

1. 从“skills”这个词说起&#xff1a;为什么它突然成了高频词“skills”这个词最近在各种场合被反复提起&#xff0c;不是没有原因的。我在几个技术社区和职场社群里潜水观察了大半年&#xff0c;发现一个很明显的趋势&#xff1a;大家讨论的不再是“你会用什么工具”&#x…

2026/10/20 4:25:39 阅读更多 →
AI大模型辅助基金申报:立项依据到创新点的全流程实操

AI大模型辅助基金申报:立项依据到创新点的全流程实操

每到申报季&#xff0c;我身边的同行们就开始进入一种“又忙又累又焦虑”的状态。2026年的国自然和省级基金申报&#xff0c;有一个信号特别明显&#xff1a;“瘦身提质”。说白了&#xff0c;就是申报书不能再靠堆字数、堆材料来撑场面了&#xff0c;要在有限的篇幅内把科学问…

2026/10/20 4:25:39 阅读更多 →
插件化知识工作流:用可插拔架构重构个人效率系统

插件化知识工作流:用可插拔架构重构个人效率系统

1. 从“知识工作插件”这个命名说起&#xff1a;它到底在解决什么问题第一次看到“knowledge-work-plugins”这个命名&#xff0c;我的直觉是&#xff1a;这不是一个具体的工具&#xff0c;而是一类架构思路的统称。它指向的是把知识工作者的日常操作——写文档、整理资料、做决…

2026/10/20 4:25:39 阅读更多 →
负面结果也值得公开:gemma-4-12b-constraintkit的SFT数据工厂与被放弃的图像转CAD实验

负面结果也值得公开:gemma-4-12b-constraintkit的SFT数据工厂与被放弃的图像转CAD实验

【免费下载链接】gemma-4-12b-constraintkit-GGUF 项目地址&#xff1a; https://ai.gitcode.com/hf_mirrors/jajmangold/gemma-4-12b-constraintkit-GGUF 点击查看 免费下载 gemma-4-12b-constraintkit 是一个基于 Gemma 4 12B 微调的 text-to-CAD&#xff08;文本生成 CAD&a…

2026/10/20 4:24:39 阅读更多 →

日新闻

CLRC663芯片Pin-to-Pin替代实战:从评估到量产全记录

CLRC663芯片Pin-to-Pin替代实战:从评估到量产全记录

/* 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:01:30 阅读更多 →
ESP32舵机控制指南:原理、多路驱动与避坑实战

ESP32舵机控制指南:原理、多路驱动与避坑实战

/* 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:01:30 阅读更多 →
位移法实现一阶低通滤波:单片机ADC采样降噪实战

位移法实现一阶低通滤波:单片机ADC采样降噪实战

/* 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:01:30 阅读更多 →

周新闻

基于GMM的语音识别系统原理与MATLAB实现详解

基于GMM的语音识别系统原理与MATLAB实现详解

说到基于高斯混合模型&#xff08;GMM&#xff09;的语音识别系统&#xff0c;很多人第一反应就是一堆数学公式&#xff0c;觉得门槛很高。但如果你真的在MATLAB里把它落地跑通&#xff0c;会发现核心其实就是三件事&#xff1a;把声音变成特征向量&#xff0c;用GMM给每个类别…

2026/10/19 10:02:04 阅读更多 →
数字图像处理入门:反白、灰度化、调色板修改与导出txt实践

数字图像处理入门:反白、灰度化、调色板修改与导出txt实践

数字图像处理这四个字听起来挺唬人&#xff0c;但落到具体操作上&#xff0c;无非就是让计算机按你的想法“摆弄”像素。我在一个模拟项目X里用了一套特别经典的基础处理流程&#xff1a;对图像做反白、改调色板、彩色转灰度&#xff0c;最后把像素数据导出成txt文件。单看每一…

2026/10/19 17:32:41 阅读更多 →
机器人大脑技术盘点:具身智能的感知、决策与数据闭环

机器人大脑技术盘点:具身智能的感知、决策与数据闭环

如果你的信息流最近也天天刷到“机器人大脑”这个词&#xff0c;你会发现一个很有意思的现象&#xff1a;每个人说的“大脑”根本不是同一个东西。有人指的是大语言模型&#xff0c;有人指的是运动控制系统&#xff0c;还有人指的是能理解物理世界的端到端模型。在2026年这个时…

2026/10/20 2:50:50 阅读更多 →

月新闻

我发现了一个新思路:用 Remotion + Claude Code 像写代码一样自动化生成短视频

我发现了一个新思路:用 Remotion + Claude Code 像写代码一样自动化生成短视频

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

2026/10/19 2:54:50 阅读更多 →
Windows下 Codex 中 Chrome 和 Computer Use 插件不可用问题排查及解决参考方式:TaoToken 统一 Key 配置与验证

Windows下 Codex 中 Chrome 和 Computer Use 插件不可用问题排查及解决参考方式:TaoToken 统一 Key 配置与验证

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

2026/10/19 9:10:22 阅读更多 →
黑夜航拍船只数据集训练YOLOV5模型全流程解析

黑夜航拍船只数据集训练YOLOV5模型全流程解析

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

2026/10/18 12:16:16 阅读更多 →