前言很多人在第一次写多线程程序时,会默认「先创建的线程先执行」「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标准里的顺序关系可以粗略分成三层: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。多个互斥量要一起获取时,优先用 C++17 的std::scoped_lock,它内部采用避免死锁的算法;次选std::lock配合std::unique_lock的std::defer_lock。不要手写「先锁 A 再锁 B」的嵌套,两个线程以相反顺序加锁就会死锁。3.2 条件变量表达「等到某条件成立」std::condition_variable必须搭配谓词使用:// 适用于 C++17std::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;// 线程 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::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 表示 Cvoid 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条件变量或 futurevolatile可以同步不提供原子性与可见性std::atomic或互斥量原子变量保护所有相关数据只保护它自己用锁保护整组不变量多线程的执行顺序从来不是「你预期怎样就怎样」,而是「你同步到哪里,就保证到哪里」。把顺序当成需要显式建立的契约,而不是调度器碰巧给出的结果,程序才能在换机器、换负载、换编译器之后仍然正确。需要提醒的是,本文所有代码都只按标准语义推演,没有在本机编译或运行;不同标准库实现对std::cout的缓冲、对条件变量唤醒顺序的处理可能不同,但上述同步逻辑本身不依赖这些实现细节。
前言很多人在第一次写多线程程序时,会默认「先创建的线程先执行」「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标准里的顺序关系可以粗略分成三层: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。多个互斥量要一起获取时,优先用 C++17 的std::scoped_lock,它内部采用避免死锁的算法;次选std::lock配合std::unique_lock的std::defer_lock。不要手写「先锁 A 再锁 B」的嵌套,两个线程以相反顺序加锁就会死锁。3.2 条件变量表达「等到某条件成立」std::condition_variable必须搭配谓词使用:// 适用于 C++17std::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;// 线程 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::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 表示 Cvoid 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条件变量或 futurevolatile可以同步不提供原子性与可见性std::atomic或互斥量原子变量保护所有相关数据只保护它自己用锁保护整组不变量多线程的执行顺序从来不是「你预期怎样就怎样」,而是「你同步到哪里,就保证到哪里」。把顺序当成需要显式建立的契约,而不是调度器碰巧给出的结果,程序才能在换机器、换负载、换编译器之后仍然正确。需要提醒的是,本文所有代码都只按标准语义推演,没有在本机编译或运行;不同标准库实现对std::cout的缓冲、对条件变量唤醒顺序的处理可能不同,但上述同步逻辑本身不依赖这些实现细节。