MzKyle头像
C++ 原子操作、内存模型与无锁并发编程封面图

C++ 原子操作、内存模型与无锁并发编程

一、并发编程的基础:原子性、数据竞争与线程安全

原子操作(Atomic Operation)是现代 C++ 并发编程的重要基础。它不仅用于实现线程安全的计数器、状态标志,还可以与内存序配合,构建生产者—消费者模型、无锁队列、共享状态发布机制等并发系统。

但需要明确:原子操作不等于线程安全,线程安全不等于无锁,无锁也不等于性能更高。

要理解现代 C++ 原子操作,首先需要区分几个基本概念。

1.1 为什么普通变量的自增不是原子操作?

考虑下面的代码:

#include <thread>

int count = 0;

void increment() {
    for (int i = 0; i < 10000; ++i) {
        ++count;
    }
}

int main() {
    std::thread t1(increment);
    std::thread t2(increment);

    t1.join();
    t2.join();
}

从源码看,++count 似乎只是一个简单操作,但它实际上包含读取、计算和写回等逻辑。

可以将其抽象为:

读取 count
    ↓
计算 count + 1
    ↓
写回 count

假设两个线程同时执行:

线程 A                 线程 B
  │                      │
读取 count = 0       读取 count = 0
  │                      │
计算得到 1           计算得到 1
  │                      │
写回 count = 1       写回 count = 1

最终 count 可能只增加一次,而不是两次。

但在 C++ 中,这个例子还存在更严重的问题:两个线程对同一个非原子对象进行无同步的冲突访问,构成数据竞争(Data Race),程序行为属于未定义行为(Undefined Behavior,UB)。

因此,不能简单地认为程序只是“偶尔算错”。编译器可以基于不存在数据竞争的语言规则进行优化,实际行为可能超出直觉预期。

1.2 原子性到底意味着什么?

原子性要求一次原子操作在并发观察下具有不可分割的语义。

例如:

std::atomic<int> count{0};

void increment() {
    for (int i = 0; i < 10000; ++i) {
        count.fetch_add(1, std::memory_order_relaxed);
    }
}

两个线程各执行 10000 次自增,最终结果必然为 20000。

这里的关键不是 CPU 绝对不能被中断,而是每次 fetch_add() 都作为一个不可分割的原子读—改—写操作参与并发执行。

原子操作甚至可能由多条机器指令或运行库代码实现。C++ 保证的是相应的抽象语义,而不是要求每个操作都对应一条 CPU 指令。

1.3 区分四个概念

概念含义
原子性(Atomicity)单次操作不可分割地生效
可见性(Visibility)线程能够在规定的同步条件下观察到其他线程的写入
有序性(Ordering)对不同内存访问施加规定的顺序约束
线程安全(Thread Safety)并发访问整体满足程序要求,不产生未定义行为或逻辑错误

原子操作解决的是操作本身的并发语义问题。整个业务逻辑是否线程安全,还需要分析同步关系、数据依赖与对象生命周期。

二、std::atomic 的实现机制与 API

C++11 引入了 <atomic>,提供统一的原子操作接口。

2.1 std::atomic 支持哪些类型?

主模板 std::atomic<T> 要求 T 满足可平凡复制(Trivially Copyable)等标准约束。

常见支持类型包括:

  • 整数类型,如 int、long。
  • 布尔类型,如 bool。
  • 指针类型,如 int*。
  • 满足要求的自定义结构体。

例如:

struct Point {
    int x;
    int y;
};

std::atomic<int> counter{0};
std::atomic<bool> ready{false};
std::atomic<int*> ptr{nullptr};
std::atomic<Point> point{Point{0, 0}};

对于 std::atomic<Point>,整个 Point 对象可以作为一次原子操作的对象,而不必分别更新 x 和 y。

但这不代表它必然由一条机器指令实现,也不代表它必然无锁。

std::atomic<std::string> 则不符合主模板的可平凡复制要求,不能直接这样使用。

现代 C++ 还提供专门的原子类型,例如 C++20 的 std::atomic<std::shared_ptr<T>>,它属于专门化接口,不能仅依据主模板的类型规则判断。

2.2 常用原子操作

操作接口作用
原子加载load()原子读取
原子存储store()原子写入
原子交换exchange()写入新值并返回旧值
原子加减fetch_add()、fetch_sub()原子读—改—写
比较并交换compare_exchange_weak()条件成立时更新,否则报告失败
比较并交换compare_exchange_strong()同样进行条件更新,但不允许仅因虚假失败而返回失败

例如:

std::atomic<int> value{10};

int old = value.exchange(20);
// old == 10,value == 20

int previous = value.fetch_add(5);
// previous == 20,value == 25

注意,fetch_add() 返回的是操作前的值。

对整数原子变量使用前置自增 ++value,表达式结果则是自增后的值。

2.3 原子操作为什么不一定无锁?

std::atomic<T> 规定的是原子语义,不保证所有类型的操作都通过无锁 CPU 指令完成。

C++ 提供了两个重要接口:

#include <atomic>
#include <iostream>

int main() {
    std::atomic<int> value{0};

    std::cout << std::boolalpha
              << value.is_lock_free() << '\n';

    std::cout
        << std::atomic<int>::is_always_lock_free
        << '\n';
}
  • is_lock_free():检查当前原子对象的相关操作是否无锁。
  • is_always_lock_free:编译期常量,表示该原子类型的操作是否始终无锁。

具体结果取决于平台、类型、对齐要求及实现。

std::atomic_flag 是 C++ 中保证无锁的原子类型,但它的无锁属性并不意味着用它实现的整个算法一定无锁或高效。

2.4 原子操作的硬件支撑

C++ 标准不要求所有处理器使用同一种原子实现。

在常见架构中:

  • x86-64:原子读—改—写操作可能使用 LOCK XADD、LOCK CMPXCHG 等指令。对于可缓存内存,现代处理器通常借助缓存一致性机制实现锁定操作,而不是简单地锁住整条内存总线。
  • ARM:可能使用 LDXR/STXR、带 Acquire/Release 语义的独占访问指令,或者 ARMv8.1-A 引入的 LSE 原子指令。
  • 不支持高效原子操作的类型:编译器或运行库可能使用内部锁等机制完成操作。

具体指令取决于目标架构、编译器、优化选项、内存序和原子类型。

因此,正确的理解是:

C++ 原子操作语义
        ↓
编译器根据目标架构生成实现
        ↓
CPU 指令、缓存一致性机制或运行库
        ↓
完成满足标准要求的原子操作

可以通过编译器生成的汇编代码观察具体实现,但不能将某个平台的实现方式直接当作 C++ 标准保证。

参考:Intel® 64 and IA-32 架构软件开发手册、std::atomic 参考文档。

std::atomic 提供统一的语言级语义,但无锁性、执行成本和具体指令都需要结合目标平台判断。

三、C++ 内存模型

3.1 Sequenced-Before:单线程内的先后关系

在同一个线程中,如果操作 A 按照 C++ 抽象机的执行规则先于操作 B,那么可以建立相应的 Sequenced-Before 关系。

例如:

int a = 10;
int b = a + 5;

这里读取 a 并计算 b 的过程具有程序所要求的先后关系。

但需要注意,源代码的书写顺序并不意味着机器指令绝对不能重排。编译器和 CPU 可以进行优化,只要最终行为满足语言规定的语义。

3.2 Synchronizes-With:跨线程同步关系

假设线程 A 写入数据,然后通过 Release 操作发布状态;线程 B 通过 Acquire 操作读取到了对应的发布值。

此时可以建立跨线程同步关系。

线程 B

线程 A

Synchronizes-With
release → acquire

写入普通数据

atomic.store(release)

atomic.load(acquire)

读取普通数据

需要强调:Release 和 Acquire 并不是只要同时出现就会自动同步。

通常需要 Acquire 操作读取到对应 Release 操作所写入的值,或者满足标准规定的 Release Sequence 等条件。

3.3 Happens-Before:判断数据竞争的关键

Happens-Before 是 C++ 并发正确性的核心关系之一。

它可以由同一线程中的 Sequenced-Before 关系,以及跨线程同步关系等传递规则建立。

例如:

线程 A:

写入 data
    │
    ▼
store(flag, release)
    │
    │  Synchronizes-With
    ▼
线程 B:

load(flag, acquire)
    │
    ▼
读取 data

如果 Acquire 读取到了相应的 Release 写入,就可以通过传递关系建立:

写入 data
    Happens-Before
读取 data

因此,在满足其他访问约束的前提下,线程 B 可以安全读取线程 A 发布的数据。

3.4 Modification Order:每个原子对象都有自己的修改顺序

C++ 规定,对同一个原子对象的修改存在该对象特有的 Modification Order。

例如:

std::atomic<int> x{0};

对 x 的原子修改必须满足该对象的修改顺序及一致性要求。

但是,不同原子对象各自的修改顺序,并不会自动组成一个覆盖所有内存访问的全局顺序。

这也是为什么不能通过“所有变量都是原子的”就直接推导出复杂程序的整体状态一定一致。

3.5 Data Race 为什么会导致未定义行为?

两个线程对同一内存位置进行冲突访问时,如果至少一个访问不是原子操作,并且二者之间没有所需的 Happens-Before 关系,就可能构成数据竞争。

例如:

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

// 线程 A
data = 42;
ready.store(true, std::memory_order_relaxed);

// 线程 B
if (ready.load(std::memory_order_relaxed)) {
    std::cout << data << '\n';
}

这里 ready 的访问本身没有数据竞争,但 Relaxed 不会建立必要的跨线程同步关系。因此,线程 A 对 data 的写入与线程 B 对 data 的读取之间没有所需的 Happens-Before 关系。

这段代码存在数据竞争,行为未定义。

原子变量没有数据竞争,不代表围绕它构建的整个程序没有数据竞争。

四、Memory Order:六种内存序的原理与选择

std::memory_order 用来规定原子操作对其他内存访问的排序和同步约束。

C++11 引入了六种内存序:

  • memory_order_relaxed
  • memory_order_consume
  • memory_order_acquire
  • memory_order_release
  • memory_order_acq_rel
  • memory_order_seq_cst

实际工程中最常使用的是 Relaxed、Acquire、Release、Acquire-Release 和 Sequentially Consistent。

4.1 六种内存序

内存序保证用途
relaxed原子性及原子对象自身的修改顺序约束,不建立同步关系独立计数器、统计值
consume基于依赖关系的排序语义一般应用通常不主动使用
acquire为后续内存访问提供 Acquire 排序约束读取已发布的数据
release为之前的内存访问提供 Release 排序约束发布已经准备好的数据
acq_rel同时具备 Acquire 和 Release 语义需要双向排序语义的读—改—写操作
seq_cstAcquire/Release 语义及参与顺序一致性排序的全序约束默认选择、需要更直观全局排序的场景

4.2 Relaxed:只需要原子性时使用

例如:

std::atomic<int> request_count{0};

void record_request() {
    request_count.fetch_add(1, std::memory_order_relaxed);
}

假设这个变量只用于统计请求数量,不负责发布其他共享数据,那么 Relaxed 通常已经足够。

它仍然保证:

  1. 每次原子更新都是原子的。
  2. 同一个原子对象的修改遵循其修改顺序规则。
  3. 多线程不会因为对这个原子变量的访问而产生普通的数据竞争。

但它不会通过这次操作建立跨线程同步,也不会自动为其他普通内存访问建立排序关系。
Relaxed 的限制是:不能仅凭 Relaxed 操作来发布和保护相关的非原子共享数据。

4.3 Release:发布之前完成的工作

例如:

data = 42;
ready.store(true, std::memory_order_release);

Release 操作适合发布已经准备好的数据。

在相应的抽象语义下,不能把 Release 之前的内存访问随意调整到它之后,从而破坏标准规定的排序关系。

但 Release 本身不会阻止其他线程读取数据。它需要与符合条件的 Acquire 操作配合,才能建立跨线程同步。

4.4 Acquire:获取已发布的数据

例如:

if (ready.load(std::memory_order_acquire)) {
    use(data);
}

如果这次 Acquire 读取到了相应 Release 发布的值,那么之前的写入可以通过 Happens-Before 关系对当前线程建立同步。

Acquire 的重点是:它为后续内存访问建立相应的排序约束。

它不是简单地要求 CPU “立即刷新所有缓存”,也不是一个无条件的全局内存屏障。

4.5 Acquire-Release:用于读—改—写操作

memory_order_acq_rel 同时具有 Acquire 和 Release 语义,主要用于原子读—改—写操作。

例如:

std::atomic<int> state{0};

int old = state.exchange(1, std::memory_order_acq_rel);

这里的交换操作既读取旧状态,又发布新状态,因此可以同时需要 Acquire 和 Release 语义。

注意,acq_rel 不能用于普通 load() 或 store()。

4.6 Sequentially Consistent:默认内存序

如果不显式指定内存序,许多 std::atomic 操作默认使用 memory_order_seq_cst。

memory_order_seq_cst为参与顺序一致性排序的原子操作提供一个满足标准约束的全序,并包含相应的 Acquire/Release 语义。

如果其他访问使用了 Relaxed 等非 Seq-Cst 操作,不能仅凭某个操作使用 Seq-Cst 就推断所有线程都能观察到完全一致的全局执行顺序。

另外,Seq-Cst 并不必然比其他内存序慢。不同架构、不同操作可能生成相同或成本接近的指令序列。

4.7 Consume:了解即可,通常优先使用 Acquire

memory_order_consume 的语义与依赖关系有关,但其实现和使用具有复杂性。

一般业务代码没有必要为了潜在的性能收益而主动采用它。除非对具体编译器、平台和依赖关系有深入理解,并经过实测验证,否则优先使用 Acquire 更容易维护。

4.8 使用内存序时的基本约束

需要特别注意:

  • load() 不能使用 memory_order_release 或 memory_order_acq_rel。
  • store() 不能使用 memory_order_acquire、memory_order_consume 或 memory_order_acq_rel。
  • compare_exchange 的失败内存序不能使用 Release 或 Acquire-Release,也不能强于成功内存序。
  • 使用 Relaxed 不会自动消除普通共享数据的竞争。

先建立正确的同步关系,再考虑降低内存序。不要把优化 Memory Order 当作并发设计的第一步。

五、Release-Acquire :生产者—消费者的数据发布

假设生产者线程负责生成数据,消费者线程需要等待数据准备完成后再读取。

5.1 使用 Release-Acquire 发布数据

#include <atomic>
#include <iostream>
#include <thread>

int shared_data = 0;
std::atomic<bool> data_ready{false};

void producer() {
    shared_data = 42;

    data_ready.store(
        true,
        std::memory_order_release
    );
}

void consumer() {
    while (!data_ready.load(
        std::memory_order_acquire
    )) {
        std::this_thread::yield();
    }

    std::cout << shared_data << '\n';
}

int main() {
    std::thread t1(producer);
    std::thread t2(consumer);

    t1.join();
    t2.join();
}

这里的 shared_data 本身不是原子变量,但在这个单次发布示例中,它仍然可以安全使用。

原因如下:

  1. 生产者先写入 shared_data。
  2. 生产者通过 Release 写入 data_ready = true。
  3. 消费者的 Acquire 读取到了这个 Release 写入的值。
  4. 两次操作建立 Synchronizes-With 关系。
  5. 生产者对 shared_data 的写入 Happens-Before 消费者对它的读取。

因此,消费者读取到的数据是 42。

这个示例的前提是:生产者只写入一次数据,消费者读取期间没有其他线程再次修改 shared_data。

如果要反复复用同一份数据,就需要额外设计状态复位、生产者和消费者之间的交接,以及缓冲区生命周期。

5.2 为什么把两处内存序改成 Relaxed 会出问题?

如果把 Release 和 Acquire 都替换成 Relaxed,data_ready 依然是原子变量,但它不再通过这组操作建立所需的同步关系。

生产者和消费者对普通变量 shared_data 的冲突访问可能构成数据竞争。

5.3 一次发布与反复发布的区别

上面的示例只有一次发布。如果改成:

生产者:
写入 Buffer A
发布 Buffer A
再次写入 Buffer A

消费者:
读取 Buffer A

即使发布和读取使用了正确的 Release-Acquire,生产者仍可能在消费者读取期间覆盖 Buffer A。

这意味着 Release-Acquire 解决的是数据发布和同步问题,而不是自动解决 Buffer 的读写所有权问题。

对于图像、点云或其他高频数据,通常还需要双缓冲、环形缓冲区、所有权转移或其他明确的读写交接协议。

六、CAS:构建无锁算法

CAS 是 Compare-And-Swap 的简称,在 C++ 中主要通过 compare_exchange_weak() 和 compare_exchange_strong() 提供。

6.1 CAS 的基本语义

CAS 可以抽象为:

读取当前原子值

如果当前值 == expected:
    原子地将值替换为 desired
    返回 true
否则:
    将实际值写回 expected
    返回 false
  • 匹配成功 = 条件成立 → 执行想要的修改,把原子变量换成 desired
  • 匹配失败 = 条件不成立 → 别人已经动过了,不修改原子变量,返回失败重试。
  1. expected 是输入 + 输出双向参数:调用时是预期值;CAS 失败,会被重写成原子变量真实值。
  2. 成功:改写原子变量,不动 expected,返回 true;
  3. 失败:不动原子变量,刷新 expected 为最新值,返回 false。

这也是无锁编程 CAS 循环的核心范式:局部变量保存预期值,交给 CAS,失败自动刷新局部变量,循环重试。

注意,这里的比较与更新是一个原子操作。

6.2 compare_exchange_weak 与 strong 的区别

接口特点
compare_exchange_weak()允许虚假失败,即使比较值相等也可能返回失败
compare_exchange_strong()不允许仅因虚假失败而返回失败

strong 仍然可能因为当前值与 expected 不相等而失败。

在循环重试的算法中,weak 通常很适合,因为某些平台可以更高效地实现它。

6.3 CAS 的正确使用方式:原子最大值

下面实现一个线程安全的最大值更新函数。

#include <atomic>

void atomic_max(
    std::atomic<int>& target,
    int candidate
) {
    // 先relaxed序读出当前target的值
    int current = target.load(std::memory_order_relaxed);

    // 循环条件两件事:
    // 1. current < candidate:才需要更新;如果current已经更大,直接退出循环,什么都不做
    // 2. CAS失败:!compare_exchange_weak 返回true代表CAS失败,继续自旋
    while (
        current < candidate &&
        !target.compare_exchange_weak(
            current,        // 预期值:期望原子变量等于current
            candidate,      // 想要写入的新值
            std::memory_order_relaxed, // CAS成功时的内存序
            std::memory_order_relaxed  // CAS失败时的内存序
        )
    ) {
        /*
         compare_exchange_weak 关键行为:
         ✅ CAS成功:返回true,不会修改current,原子变量被写成candidate,while条件取反,循环退出
         ❌ CAS失败:返回false,**自动把current赋值为原子变量此刻真实的最新值**
         循环体是空的!失败后current已经被更新,直接回到while头部做条件判断
        */
    }
}

假设多个线程同时尝试写入不同的候选值:

std::atomic<int> maximum{0};

atomic_max(maximum, 10);
atomic_max(maximum, 20);
atomic_max(maximum, 15);

最终 maximum 至少为 20;如果没有其他写入者将其更新到更大值,则最终值为 20。

这里可以使用 Relaxed,是因为算法只依赖原子整数本身的比较与更新,不负责发布其他非原子数据。

6.4 CAS 为什么通常需要循环?

CAS 的更新有一个前提:实际值必须与 expected 相等。

假设线程 A 读取到 10,准备更新为 20;在它执行 CAS 前,线程 B 已经把值改为 15。

此时线程 A 的 CAS 会失败,并将 expected 更新为 15。

如果业务仍然要求把值更新到 20,线程 A 就需要根据新的实际值重新判断并尝试更新。

这就是 CAS 循环的基本模式。

6.5 CAS 不能保证初始化函数只执行一次

例如:

if (resource.compare_exchange_strong(
        expected,
        init_resource()
    )) {
    // ...
}

这里的 init_resource() 会在 CAS 调用前求值。因此,多个线程可能都执行初始化函数,只是最终只有一个线程成功发布指针。

如果需要保证初始化逻辑只成功执行一次,通常应优先考虑 std::call_once:

#include <memory>
#include <mutex>

struct Resource {
    int value = 42;
};

std::once_flag init_flag;
std::unique_ptr<Resource> resource;

Resource& get_resource() {
    std::call_once(init_flag, [] {
        resource = std::make_unique<Resource>();
    });

    return *resource;
}

std::call_once 可以保证成功执行的初始化函数只执行一次,并建立相应的同步关系。

CAS 适合构建需要自定义状态转换的算法,但不能因为它是原子操作,就认为整个初始化、资源分配和对象生命周期都已安全。

七、C++20 的现代原子接口:Wait/Notify、atomic_ref 与原子智能指针

现代 C++ 的原子操作不只有 load()、store() 和 CAS。

C++20 还提供了更实用的等待与通知接口,以及针对现有对象和共享所有权的原子操作。

7.1 atomic::wait 与 notify:避免无意义的忙等

前面的生产者—消费者示例使用循环持续检查状态:

while (!ready.load()) {
}

这种忙等会不断消耗 CPU 资源。

C++20 提供 wait()、notify_one() 和 notify_all(),可以根据原子变量的值等待状态发生变化。

#include <atomic>
#include <iostream>
#include <thread>

std::atomic<int> state{0};
int shared_data = 0;

void producer() {
    shared_data = 42;

    state.store(1, std::memory_order_release);
    state.notify_one();
}

void consumer() {
    state.wait(0, std::memory_order_acquire);

    std::cout << shared_data << '\n';
}

int main() {
    std::thread t1(producer);
    std::thread t2(consumer);

    t1.join();
    t2.join();
}

这里:

  1. 消费者在 state 仍为 0 时等待。
  2. 生产者写入数据后,将 state 更新为 1。
  3. 生产者调用 notify_one(),通知等待线程。
  4. 消费者观察到状态发生变化后返回,并读取数据。

如果生产者在消费者开始等待之前就完成了状态更新,消费者也不会因此永久错过这次状态变化:wait(0) 会检查当前值,发现它不再等于 0 时即可返回。

需要注意,真正建立内存同步关系的是 Release 写入与符合条件的 Acquire 观察;notify_one() 本身不是数据发布操作。

另外,wait() 不是消息队列。它主要根据值是否变化进行等待,如果状态从 0 变为 1 又变回 0,等待者可能无法观察到这次短暂变化。这是设计状态协议时需要考虑的问题。

7.2 atomic_ref:对已有对象进行原子访问

C++20 的 std::atomic_ref<T> 可以为符合要求的现有对象提供原子访问接口。

#include <atomic>

int value = 0;

void update() {
    std::atomic_ref<int> ref(value);
    ref.fetch_add(1, std::memory_order_relaxed);
}

但这并不意味着可以随意混合普通访问与原子访问。

使用 atomic_ref 时必须满足其对齐要求和对象生命周期约束。在 atomic_ref 实例存续期间,对被引用对象的访问必须遵守标准规定的原子访问要求,不能再让其他线程通过普通非原子访问无同步地读写同一对象。

因此,atomic_ref 更适合有明确访问协议的场景,而不是用来给现有多线程代码简单地“套一层原子包装”。

7.3 atomic<shared_ptr>:原子发布共享对象

C++20 提供了 std::atomic<std::shared_ptr<T>> 专门化,可以原子地发布或替换共享所有权指针。

例如,多个线程需要读取一个只读配置对象,而另一个线程偶尔更新配置:

#include <atomic>
#include <memory>
#include <utility>

// 配置结构体,存放业务参数
struct Config {
    int threshold;
};

// 全局原子共享指针:保存当前正在生效的配置
// 指向const Config:配置一旦发布就不可修改,避免并发写
std::atomic<std::shared_ptr<const Config>> current_config{
    std::shared_ptr<const Config>{}
};

// 发布新配置:生产者线程调用,安装新版本配置
void publish_config(Config config) {
    // 将传入的配置移动,构造全新的const Config对象
    auto next = std::make_shared<const Config>(
        std::move(config)
    );
    // 原子存储新shared_ptr,release内存序
    current_config.store(
        std::move(next),
        std::memory_order_release
    );
}

// 获取当前配置:消费者线程调用,拿到当前配置快照
std::shared_ptr<const Config> get_config() {
    // 原子读取shared_ptr,acquire内存序
    return current_config.load(
        std::memory_order_acquire
    );
}

这个设计有几个特点:

  • 发布操作原子地替换当前配置指针。
  • 消费者通过 Acquire 获取已经发布的配置。
  • shared_ptr 的局部副本可以延长对象生命周期。
  • 使用 const Config 可以避免消费者通过该接口修改配置。

需要强调:std::atomic<std::shared_ptr<T>> 不保证无锁。引用计数操作也会产生一定开销。

std::atomic<std::shared_ptr<const Config>>不修改旧 Config 对象,永远新建一份 Config,替换指针。旧配置对象依靠 shared_ptr 引用计数自动回收。

对于高频图像或点云管线,如果需要严格控制延迟和内存分配,仍然需要结合缓冲区复用、所有权转移和吞吐量要求评估设计。

八、Atomic、Mutex 与 Lock-Free

8.1 原子操作和互斥锁

对比维度std::atomicstd::mutex
主要用途原子状态更新、数据发布、构建并发原语保护临界区与复杂共享状态
保护范围原子对象及其操作语义可以保护多个变量和一段业务逻辑
是否无锁不一定否
是否可能阻塞线程原子操作本身的接口语义不要求调用者显式加锁,但实现可能使用内部锁竞争时可以阻塞
编程复杂度简单用法较低,复杂无锁算法很高对复杂共享状态通常更直观
常见问题内存序错误、ABA、生命周期问题死锁、锁竞争、持锁时间过长

例如:

std::atomic<int> counter{0};

适合独立计数器。

但如果需要同时修改:

int balance_a;
int balance_b;

并保证两个余额的整体不变量,那么使用互斥锁通常更清晰:

std::mutex mtx;

void transfer(int amount) {
    std::lock_guard<std::mutex> lock(mtx);

    balance_a -= amount;
    balance_b += amount;
}

这里的重点是:两个变量的修改需要作为一个整体受到保护。

8.2 为什么 Mutex 不一定很慢?

在 Linux 等常见系统中,互斥锁实现可以通过用户态快速路径处理无竞争情况;竞争严重时,才可能借助 futex 等机制进入内核等待。

因此,不能简单认为:

Atomic = 纳秒级
Mutex  = 微秒级

这种固定延迟划分没有普遍意义。

原子操作可能因为缓存行争用而变慢;互斥锁则可能在无竞争情况下很高效。

真正需要比较的是具体程序在目标平台上的延迟分布、吞吐量、竞争程度和 CPU 消耗。

8.3 Lock-Free、Wait-Free 与 Atomic 的区别

  • Atomic: 操作满足原子语义。
  • Lock-Free: 算法具有整体进展保证,在持续执行的并发操作中,不会因为某一个线程被阻塞而使所有线程都无法继续取得进展。
  • Wait-Free: 每个操作都能在有限且有界的自身执行步骤内完成。

Lock-Free 不保证每个线程都公平地完成任务,也不保证单次操作延迟固定。

Wait-Free 的要求更强,但实现通常更复杂。

即使使用了无锁的原子指令,整个算法仍可能因为设计错误而陷入死循环、活锁或无限重试。

无锁是算法的进展保证,不是使用 std::atomic 后自动获得的属性。

九、无锁编程的难点:ABA、对象生命周期与缓存争用

无锁算法真正困难的部分是证明整个状态转换正确。

9.1 ABA 问题

假设一个原子指针最初指向对象 A:

初始状态:A
    ↓
被修改为:B
    ↓
再次变为:A

线程在第一次读取时观察到 A,随后暂停。等它恢复时,指针又变回 A。

CAS 只比较当前值与 expected,可能无法判断中间已经发生过变化。

这就是 ABA 问题。

ABA 不一定意味着程序必然错误,但如果算法依赖“状态期间从未变化”这一假设,就可能产生逻辑错误。

常见解决思路包括:

  • 使用带版本号的状态,比较指针与版本信息。
  • 使用 Hazard Pointer 保护正在访问的对象。
  • 使用 Epoch-Based Reclamation 等延迟回收机制。
  • 设计明确的对象所有权与生命周期协议。

带版本号的指针本身也有实现限制,例如原子类型的支持和是否无锁,不能假定任意宽度的组合都能由硬件直接完成。

9.2 原子指针不保证对象生命周期安全

例如:

std::atomic<Node*> head{nullptr};

即使 head 的加载、交换和 CAS 都是原子的,也不能保证读取到的 Node* 一定指向仍然存活的对象。

如果另一个线程已经释放对应节点,那么解引用该指针就可能产生 Use-After-Free。

因此:

指针的原子性、指向对象的生命周期,以及对象内容的并发访问安全,是三个独立问题。

在实现无锁链表、栈和队列时,必须明确解决对象回收问题。

9.3 atomic_flag 与自旋锁

std::atomic_flag 是最基础的原子标志类型,可用于实现自旋锁。

#include <atomic>

class SpinLock {
private:
    std::atomic_flag flag = ATOMIC_FLAG_INIT;

public:
    void lock() noexcept {
        while (flag.test_and_set(
            std::memory_order_acquire
        )) {
            // 忙等
        }
    }

    void unlock() noexcept {
        flag.clear(std::memory_order_release);
    }
};

获取锁时使用 Acquire,释放锁时使用 Release,从而在前一个持锁线程释放锁与后一个线程成功获取锁之间建立必要的同步关系。

但是,自旋锁也有明显限制:

  • 等待期间持续消耗 CPU。
  • 锁竞争会引发缓存一致性流量。
  • 不保证公平性。
  • 如果持锁线程被抢占,其他线程可能长时间空转。

C++20 的 atomic_flag 还提供 test()、wait() 和 notify_*() 等接口,可以用于减少某些场景中的忙等。

在普通业务代码中,通常优先使用 std::mutex。只有在临界区极短、竞争模式明确,并且经过测量验证时,才应考虑自旋锁。

9.4 False Sharing:没有数据竞争,也可能很慢

假设两个线程分别更新两个独立的原子变量:

std::atomic<int> counter_a{0};
std::atomic<int> counter_b{0};

即使它们不存在数据竞争,如果两个变量恰好位于同一缓存行,两个核心的写入也可能导致缓存行反复转移。

这种现象称为 False Sharing(伪共享)。

C++17 提供了 std::hardware_destructive_interference_size,可以辅助设计缓存行隔离布局:

#include <atomic>
#include <new>

struct alignas(
    std::hardware_destructive_interference_size
) Counter {
    std::atomic<int> value{0};
};

Counter counter_a;
Counter counter_b;

这种方法可能减少伪共享,但不能保证所有平台都以同样方式布局,也会增加内存占用。

因此,应该先使用性能分析工具确认缓存行争用,再决定是否调整布局。

十、设计、验证和优化原子操作

10.1 先定义共享状态与所有权

在编写代码前,先回答以下问题:

  1. 哪些数据由多个线程访问?
  2. 哪个线程负责写入,哪些线程负责读取?
  3. 是否允许多个写入者?
  4. 数据发布之后是否会被再次修改?
  5. 谁负责对象的创建、释放和复用?
  6. 是否需要保证多个变量作为一个整体更新?

这些问题决定了应该使用原子变量、互斥锁、消息队列还是其他同步机制。

10.2 优先选择容易证明正确的方案

建议按以下顺序考虑:

明确业务不变量
        ↓
优先选择简单、正确的同步方式
        ↓
验证吞吐量与延迟
        ↓
定位实际瓶颈
        ↓
再考虑降低内存序或采用无锁算法

例如:

  • 独立统计计数器:通常可以使用 Relaxed。
  • 线程退出标志:如果只表达退出状态,Relaxed 可能足够;如果同时发布其他数据,需要重新分析同步关系。
  • 只读配置更新:可以考虑 Release-Acquire 与不可变对象。
  • 多变量状态转移:优先考虑 Mutex,或者在满足约束时原子地更新整体状态。
  • 高频数据缓冲区:除了原子发布,还要解决缓冲区所有权、复用与回收问题。

10.3 通过工具验证并发正确性

建议将工具用于不同层面的问题:

工具主要用途
ThreadSanitizer(TSan)检测许多运行路径上的数据竞争
AddressSanitizer(ASan)检测越界、Use-After-Free 等内存错误
GDB分析线程状态、死锁和异常执行路径
perf 等性能工具分析 CPU 使用、缓存行为和性能瓶颈
基准测试比较不同同步策略的吞吐量与延迟

例如,在支持相应工具链的环境中,可以使用:

g++ -std=c++20 -O1 -g \
    -fsanitize=thread \
    -pthread main.cpp -o app

但需要注意:

  • TSan 没有报告问题,不代表程序一定没有并发错误。
  • TSan 不能证明某个算法具有 Lock-Free 或 Wait-Free 进展保证。
  • 性能测试必须考虑线程数、CPU 亲和性、缓存状态、竞争程度和测试负载。
  • 不能只比较一次执行的平均耗时,还应关注尾延迟和 CPU 消耗。

10.4 编写原子操作时的检查清单

在提交并发代码前,至少检查以下内容:

  • 每个共享对象的并发读写是否符合 C++ 内存模型?
  • 普通非原子数据是否通过有效同步关系进行发布和访问?
  • 选用的 Memory Order 是否满足算法需要?
  • CAS 失败时是否正确处理 expected 的更新?
  • 是否存在 ABA、Use-After-Free 或其他生命周期问题?
  • 原子对象的操作是否真的无锁?
  • 是否可能出现高竞争、伪共享或无限重试?
  • 是否通过压力测试、数据竞争检测和性能测量验证?

总结:C++ 并发编程

  1. 原子操作层: 操作本身是否不可分割。
  2. 内存模型层: 如何建立 Sequenced-Before、Synchronizes-With 和 Happens-Before 关系。
  3. 同步设计层: 如何正确发布数据、协调线程并维护共享状态。
  4. 算法层: 如何处理 CAS 重试、ABA、对象生命周期与进展保证。
  5. 工程性能层: 如何评估锁竞争、缓存行争用、内存序开销与实际吞吐量。

可以用一句话概括:

原子性解决单次操作的不可分割问题;内存模型定义并发访问的合法语义;同步协议保证共享数据的正确交接;无锁算法则需要进一步证明状态转换、对象生命周期和进展保证。

对于普通应用开发,正确使用 std::atomic 和 std::mutex 已经可以解决大量问题。对于高吞吐、低延迟系统,才需要进一步研究 Memory Order、Lock-Free、ABA 和内存回收机制。

最终目标是构建一个正确、可验证、可维护,并且经过实际性能测量的并发系统。

转载自 CSDN-专业IT技术社区

原文链接:https://blog.csdn.net/2301_80079642/article/details/167473683

文章来源转载

评论

评论列表

微信小程序
QQ小程序

关于作者

点赞数:0
关注数:0
粉丝:0
文章:0
关注标签:0
加入于:--