目录

题目描述

拆解原子性的本质(Deconstruction)

构建“自旋”逻辑(Spin-waiting)

编写 C++ 代码

深度反思与优化

std::atomic

原子操作的两个核心真理

在 Foo 题目中的原子“自旋”模型

为什么原子操作通常比锁快?

深度概念:内存序(Memory Order)

什么时候不该用原子操作?


题目描述

给你一个类:

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)解决这道题,是对计算机底层运行逻辑的一次深度拆解。让我们继续用第一性原理来推导。

https://blog.csdn.net/2402_88047672/article/details/156695383?fromshare=blogdetail&sharetype=blogdetail&sharerId=156695383&sharerefer=PC&sharesource=2402_88047672&sharefrom=from_link


拆解原子性的本质(Deconstruction)

在之前的方案中,我们依赖操作系统提供的“红绿灯”(锁或信号量)来停下线程。但如果我们剥离这些复杂的抽象,回归到硬件层面的真理:

  1. 可见性问题: 在多核 CPU 中,一个线程改了变量,另一个线程可能因为“缓存”而看不到最新的值。

  2. 原子性问题: 普通变量的加减操作在底层是多步指令。如果两个线程同时改,可能会出现数据竞争。

  3. 核心真理: std::atomic 保证了某个变量的操作是“不可分割”的,并且在所有线程之间是即时可见的。

第一性原理的推论: 既然我们只需要控制顺序,我们可以设立一个“全剧终”状态变量。每个线程不停地“观察”这个变量,直到它变成自己期待的那个数字。


构建“自旋”逻辑(Spin-waiting)

不同于互斥锁让线程“睡觉”,原子操作通常配合“自旋”使用:

  • 状态设计: 设一个原子整数 state

    • 1:轮到 first 了。

    • 2:轮到 second 了。

    • 3:轮到 third 了。

  • 动作逻辑: * second 线程进场后,会进入一个死循环:while (state != 2) { /* 什么也不做,只是看 */ }

    • 一旦 firststate 改成了 2second 瞬间跳出循环执行任务。


编写 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 变成了一个所有线程都盯着看的“公共信号灯”。

逻辑拆解:

  1. 线程 A (first): * 它直接干活。干完后,执行 state.store(2)

    • 这步操作不仅改了值,还发出了一个“内存屏障”信号,告诉系统:我之前的操作全部完成了。

  2. 线程 B (second): * 它在跑 while (state.load() != 2);。这叫自旋(Spinning)

    • CPU 会以极高的频率重复检查内存。一旦 state 变为 2,它立刻跳出循环。

  3. 线程 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”之前完成。


什么时候不该用原子操作?

虽然原子操作很快,但它不是万能的:

  1. 极耗 CPU: 像这道题,如果 first 要跑 1 秒钟,second 就会在这一秒内把一个 CPU 核心占到 100%。这在移动设备上是电量杀手。

  2. 逻辑复杂性: 原子操作只适合简单的状态标记。如果你要保护一个复杂的链表,用原子操作实现“无锁数据结构”的难度是普通锁的 10 倍以上。

Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐