多线程:按序打印问题(原子操作)
本文探讨了使用原子操作解决多线程顺序执行问题的方法。通过定义原子状态变量state,实现了线程间的同步:first()执行后修改state为2,second()自旋等待state=2后执行并修改为3,third()等待state=3后执行。相比互斥锁,原子操作无需上下文切换,直接利用CPU指令实现高效同步,但会持续占用CPU资源。文章还介绍了C++20的wait/notify优化方案,并分析了原子
目录
题目描述
给你一个类:
class Foo {
public:
Foo() {
}
void first(function<void()> printFirst) {
// printFirst() outputs "first". Do not change or remove this line.
printFirst();
}
void second(function<void()> printSecond) {
// printSecond() outputs "second". Do not change or remove this line.
printSecond();
}
void third(function<void()> printThird) {
// printThird() outputs "third". Do not change or remove this line.
printThird();
}
};
三个不同的线程 A、B、C 将会共用一个 Foo 实例。
-
线程 A 会调用 first() 方法;
-
线程 B 会调用 second() 方法;
-
线程 C 会调用 third() 方法;
请设计修改程序,以确保 second() 方法在 first() 方法之后被执行,third() 方法在 second() 方法之后被执行。
示例 1:
输入:nums = [1,2,3]
输出:"firstsecondthird"
解释:
有三个线程会被异步启动。输入 [1,2,3] 表示线程 A 将会调用 first() 方法,线程 B 将会调用 second() 方法,线程 C 将会调用 third() 方法。正确的输出是 "firstsecondthird"。
示例 2:
输入:nums = [1,3,2]
输出:"firstsecondthird"
解释:
输入 [1,3,2] 表示线程 A 将会调用 first() 方法,线程 B 将会调用 third() 方法,线程 C 将会调用 second() 方法。正确的输出是 "firstsecondthird"。
(来源:Leecode)
使用原子操作(Atomic Operations)解决这道题,是对计算机底层运行逻辑的一次深度拆解。让我们继续用第一性原理来推导。
拆解原子性的本质(Deconstruction)
在之前的方案中,我们依赖操作系统提供的“红绿灯”(锁或信号量)来停下线程。但如果我们剥离这些复杂的抽象,回归到硬件层面的真理:
-
可见性问题: 在多核 CPU 中,一个线程改了变量,另一个线程可能因为“缓存”而看不到最新的值。
-
原子性问题: 普通变量的加减操作在底层是多步指令。如果两个线程同时改,可能会出现数据竞争。
-
核心真理:
std::atomic保证了某个变量的操作是“不可分割”的,并且在所有线程之间是即时可见的。
第一性原理的推论: 既然我们只需要控制顺序,我们可以设立一个“全剧终”状态变量。每个线程不停地“观察”这个变量,直到它变成自己期待的那个数字。
构建“自旋”逻辑(Spin-waiting)
不同于互斥锁让线程“睡觉”,原子操作通常配合“自旋”使用:
-
状态设计: 设一个原子整数
state。-
1:轮到first了。 -
2:轮到second了。 -
3:轮到third了。
-
-
动作逻辑: *
second线程进场后,会进入一个死循环:while (state != 2) { /* 什么也不做,只是看 */ }。-
一旦
first把state改成了2,second瞬间跳出循环执行任务。
-
编写 C++ 代码
1. 定义原子状态
我们需要引入 <atomic> 头文件。
#include <atomic>
class Foo {
private:
// 第一性原理:原子变量保证了跨线程的内存可见性
std::atomic<int> state;
public:
Foo() : state(1) { // 初始状态为 1
}
2. first 的逻辑:直接起跑
first 是链条的起点,它不需要等任何人。
void first(function<void()> printFirst) {
// 执行任务
printFirst();
// 关键一步:原子性地将状态改为 2
// 这会通知正在“自旋”的 second 线程
state.store(2);
}
3. second 的逻辑:自旋等待
second 会在门口疯狂敲门,直到 state 变成 2。
void second(function<void()> printSecond) {
// 自旋:只要不是 2,我就死循环
while (state.load() != 2) {
// 这里可以稍微“休息”一下,防止 CPU 占用率 100%
// std::this_thread::yield();
}
printSecond();
// 执行完,把接力棒传给 3
state.store(3);
}
4. third 的逻辑:同样的守望
void third(function<void()> printThird) {
// 等待状态变为 3
while (state.load() != 3) {
// 自旋等待
}
printThird();
}
};
深度反思与优化
1. 性能的代价
原子自旋(Busy Waiting)是最快的响应方式,因为它省去了线程被操作系统挂起和唤醒的开销。但它也是最昂贵的,因为它会让一个 CPU 核心满载运行。
2. C++20 的新武器:std::atomic::wait
为了平衡“原子操作的快”和“挂起线程的省”,C++20 给原子变量增加了类似条件变量的功能:
// C++20 风格优化
void second(function<void()> printSecond) {
// 只有当值不是 2 时,才进入高效等待(类似于挂起)
state.wait(1);
printSecond();
state.store(3);
state.notify_all(); // 唤醒正在 wait 的人
}
你会发现,原子操作方案是唯一一个不需要操作系统介入(在基本自旋模式下)的方案。它直接利用了 CPU 的缓存一致性协议(MESI)。这是你作为程序员,能触碰到的距离硬件最近的指挥棒。
到目前为止,我们已经用四种方式解决了同一个问题。它们反映了并发编程的不同维度:
| 方案 | 第一性原理的本质 | 适用场景 |
|---|---|---|
| 互斥锁 (Mutex) | 排他权:保护资源,预防冲突。 | 通用、最安全。 |
| 信号量 (Semaphore) | 计票制:控制流量和依赖。 | 任务接力、资源池限制。 |
| 异步 (Promise/Future) | 契约制:关注最终结果的交付。 | 高层业务逻辑、IO 操作。 |
| 原子操作 (Atomic) | 可见性:最底层的状态同步。 | 追求极致性能、无锁数据结构。 |
-
Mutex/CV: 像管理公司(有行政流程,会放假休息)。
-
Semaphore: 像管理交通(靠红绿灯和通行证)。
-
Future/Promise: 像管理物流(靠快递单和收据)。
-
Atomic: 像管理粒子(直接利用物理定律,最快也最危险)。
std::atomic
结合这道题,我们进入并发编程的物理层。如果说互斥锁是“宏观的规章制度”,那么原子操作(Atomic Operations)就是“微观的物理定律”。
在 C++ 中,std::atomic 提供的操作能保证在多线程环境下,对变量的读写是不可分割且内存可见的。
原子操作的两个核心真理
从第一性原理出发,原子操作解决了两个最基本的问题:
A. 最小单元性(Atomicity)
普通的 int++ 在底层分为:读取 -> 修改 -> 写回 三个指令。如果线程 A 刚读完,线程 B 就改了,数据就脏了。原子操作保证这三步像一个“黑盒子”,要么全部完成,要么完全没开始,中间不会被任何人插足。
B. 内存可见性(Memory Visibility)
这是最容易被忽视的。现代 CPU 每个核心都有自己的 L1/L2 缓存。线程 A 修改了变量,可能只改在了自己的缓存里,线程 B 在另一个核心上看,那个变量还是旧值。 原子操作会强制触发缓存一致性协议,确保线程 A 的修改立刻被线程 B “看到”。
在 Foo 题目中的原子“自旋”模型
我们在代码中定义了 std::atomic<int> state{1};。此时,state 变成了一个所有线程都盯着看的“公共信号灯”。
逻辑拆解:
-
线程 A (first): * 它直接干活。干完后,执行
state.store(2)。-
这步操作不仅改了值,还发出了一个“内存屏障”信号,告诉系统:我之前的操作全部完成了。
-
-
线程 B (second): * 它在跑
while (state.load() != 2);。这叫自旋(Spinning)。-
CPU 会以极高的频率重复检查内存。一旦
state变为 2,它立刻跳出循环。
-
-
线程 C (third): * 同理,它在等
state.load() == 3。
为什么原子操作通常比锁快?
-
无上下文切换: 互斥锁如果拿不到锁,操作系统会把线程“踢出”CPU,换别的线程上。等锁开了,再“唤醒”它。这个切换过程非常昂贵(微秒级)。
-
直接硬件支持: 原子操作直接映射为 CPU 的指令(如 x86 上的
LOCK前缀指令)。它不睡觉,就在那儿死等,响应速度是纳秒级的。
深度概念:内存序(Memory Order)
这是原子操作里最“玄学”的部分。默认情况下,原子操作使用 std::memory_order_seq_cst(顺序一致性)。
第一性原理思考: CPU 为了快,有时会偷偷重排你的指令顺序。 假设你写了:
data = 42; // 操作 1
state.store(2); // 操作 2
如果没有原子序的保证,CPU 可能觉得先跑“操作 2”更顺手。结果 second 看到 state 变 2 了,跑去读 data,却读到了一个还没初始化的值!
std::atomic 的默认行为就是防止这种指令重排,确保“操作 1”一定在“操作 2”之前完成。
什么时候不该用原子操作?
虽然原子操作很快,但它不是万能的:
-
极耗 CPU: 像这道题,如果
first要跑 1 秒钟,second就会在这一秒内把一个 CPU 核心占到 100%。这在移动设备上是电量杀手。 -
逻辑复杂性: 原子操作只适合简单的状态标记。如果你要保护一个复杂的链表,用原子操作实现“无锁数据结构”的难度是普通锁的 10 倍以上。
更多推荐


所有评论(0)