14、原子操作:与硬件协同工作

原子操作:与硬件协同工作

1. 原子操作基础函数

在多线程编程中,有一些函数用于创建内存屏障以及检查原子操作是否无锁:
- bool __atomic_always_lock_free (size_t size, void *ptr) :该函数检查指定大小的对象是否总是能为当前处理器架构创建无锁的原子指令。
- bool __atomic_is_lock_free (size_t size, void *ptr) :此函数本质上与 __atomic_always_lock_free 相同。

2. 内存顺序

在C++11内存模型的原子操作中,内存屏障并非总是必要的。在GCC内置的原子API中,这体现在函数的 memorder 参数上,其可能的值与C++11原子API中的值直接对应:
| 内存顺序 | 描述 |
| ---- | ---- |
| __ATOMIC_RELAXED | 意味着没有线程间的顺序约束。 |
| __ATOMIC_CONSUME | 由于C++11中 memory_order_consume 语义的不足,目前使用更强的 __ATOMIC_ACQUIRE 内存顺序实现。 |
| __ATOMIC_ACQUIRE | 从释放(或更强)语义的存储到该获取加载创建线程间的“先于发生”约束。 |
| __ATOMIC_RELEASE | 为从该释放存储读取的获取(或更强)语义加载创建线程间的“先于发生”约束。 |
| __ATOMIC_ACQ_REL | 结合了 __ATOMIC_ACQUIRE __ATOMIC_RELEASE 的效果。 |
| __ATOMIC_SEQ_CST | 与所有其他 __ATOMIC_SEQ_CST 操作强制执行总顺序。 |

3. 不同编译器的原子操作支持

除了VC++和GCC,还有许多C/C++编译器工具链,如Intel Compiler Collection (ICC) 等。这些编译器都有自己的内置原子函数集。不过,得益于C++11标准,现在有了一个跨编译器的完全可移植的原子标准。通常,除了非常特定的用例或维护现有代码外,建议使用C++标准而不是特定于编译器的扩展。

4. C++11原子特性

要使用原生的C++11原子特性,只需包含 <atomic> 头文件。这将提供 atomic 类,它使用模板来适应所需的类型,并提供了大量预定义的 typedef
| Typedef 名称 | 完全特化 |
| ---- | ---- |
| std::atomic_bool | std::atomic<bool> |
| std::atomic_char | std::atomic<char> |
| std::atomic_schar | std::atomic<signed char> |
| … | … |

atomic 类定义了以下通用函数:
| 函数 | 描述 |
| ---- | ---- |
| operator= | 为原子对象赋值。 |
| is_lock_free | 如果原子对象是无锁的,则返回 true 。 |
| store | 原子地用非原子参数替换原子对象的值。 |
| load | 原子地获取原子对象的值。 |
| operator T | 从原子对象加载值。 |
| exchange | 原子地用新值替换对象的值并返回旧值。 |
| compare_exchange_weak
compare_exchange_strong | 原子地比较对象的值,如果相等则交换值,否则返回当前值。 |

C++17更新添加了 is_always_lock_free 常量,用于查询类型是否总是无锁的。

此外,还有专门的原子函数:
| 函数 | 描述 |
| ---- | ---- |
| fetch_add | 原子地将参数添加到原子对象存储的值中并返回旧值。 |
| fetch_sub | 原子地从原子对象存储的值中减去参数并返回旧值。 |
| fetch_and | 原子地对参数和原子对象的值执行按位与操作并返回旧值。 |
| fetch_or | 原子地对参数和原子对象的值执行按位或操作并返回旧值。 |
| fetch_xor | 原子地对参数和原子对象的值执行按位异或操作并返回旧值。 |
| operator++
operator++(int)
operator--
operator--(int) | 原子值加一或减一。 |
| operator+=
operator-=
operator&=
operator|=
operator^= | 对原子值进行加、减或按位与、或、异或操作。 |

以下是一个使用 fetch_add 的基本示例:

#include <iostream>
#include <thread>
#include <atomic>
std::atomic<long long> count;
void worker() {
    count.fetch_add(1, std::memory_order_relaxed);
}
int main() {
    std::thread t1(worker);
    std::thread t2(worker);
    std::thread t3(worker);
    std::thread t4(worker);
    std::thread t5(worker);
    t1.join();
    t2.join();
    t3.join();
    t4.join();
    t5.join();
    std::cout << "Count value:" << count << '\n';
}

该示例代码的结果是 5 。通过这种方式,可以使用原子操作实现一个基本的计数器,而无需使用任何互斥锁或类似的同步机制。

5. 非类函数

除了 atomic 类, <atomic> 头文件中还定义了许多基于模板的函数,使用方式更类似于编译器的内置原子函数:
| 函数 | 描述 |
| ---- | ---- |
| atomic_is_lock_free | 检查原子类型的操作是否无锁。 |
| atomic_store
atomic_store_explicit | 原子地用非原子参数替换原子对象的值。 |
| atomic_load
atomic_load_explicit | 原子地获取原子对象存储的值。 |
| atomic_exchange
atomic_exchange_explicit | 原子地用非原子参数替换原子对象的值并返回原子的旧值。 |
| atomic_compare_exchange_weak
atomic_compare_exchange_weak_explicit
atomic_compare_exchange_strong
atomic_compare_exchange_strong_explicit | 原子地比较原子对象的值与非原子参数,如果相等则执行原子交换,否则执行原子加载。 |
| atomic_fetch_add
atomic_fetch_add_explicit | 将非原子值添加到原子对象并获取原子的前一个值。 |
| atomic_fetch_sub
atomic_fetch_sub_explicit | 从原子对象中减去非原子值并获取原子的前一个值。 |
| atomic_fetch_and
atomic_fetch_and_explicit | 用与非原子参数的逻辑与结果替换原子对象并获取原子的前一个值。 |
| atomic_fetch_or
atomic_fetch_or_explicit | 用与非原子参数的逻辑或结果替换原子对象并获取原子的前一个值。 |
| atomic_fetch_xor
atomic_fetch_xor_explicit | 用与非原子参数的逻辑异或结果替换原子对象并获取原子的前一个值。 |
| atomic_flag_test_and_set
atomic_flag_test_and_set_explicit | 原子地将标志设置为 true 并返回其前一个值。 |
| atomic_flag_clear
atomic_flag_clear_explicit | 原子地将标志的值设置为 false 。 |
| atomic_init | 对默认构造的原子对象进行非原子初始化。 |
| kill_dependency | 从 std::memory_order_consume 依赖树中移除指定对象。 |
| atomic_thread_fence | 通用的依赖于内存顺序的栅栏同步原语。 |
| atomic_signal_fence | 线程与在同一线程中执行的信号处理程序之间的栅栏。 |

普通函数和显式函数的区别在于,显式函数允许设置要使用的内存顺序,而普通函数总是使用 memory_order_seq_cst 作为内存顺序。

以下是一个使用 atomic_fetch_sub 的示例,多个线程可以并发处理索引容器而无需使用锁:

#include <string>
#include <thread>
#include <vector>
#include <iostream>
#include <atomic>
#include <numeric>

const int N = 10000;
std::atomic<int> cnt;
std::vector<int> data(N);

void reader(int id) {
    for (;;) {
        int idx = atomic_fetch_sub_explicit(&cnt, 1, std::memory_order_relaxed);
        if (idx >= 0) {
            std::cout << "reader " << std::to_string(id) << " processed item "
                      << std::to_string(data[idx]) << '\n';
        }
        else {
            std::cout << "reader " << std::to_string(id) << " done.\n";
            break;
        }
    }
}

int main() {
    std::iota(data.begin(), data.end(), 1);
    cnt = data.size() - 1;
    std::vector<std::thread> v;
    for (int n = 0; n < 10; ++n) {
        v.emplace_back(reader, n);
    }
    for (std::thread& t : v) {
        t.join();
    }
}

该示例代码使用一个大小为 N 的整数向量作为数据源,将原子计数器对象设置为数据向量的大小。然后创建 10 个线程,运行 reader 函数。在 reader 函数中,使用 atomic_fetch_sub_explicit 函数从内存中读取索引计数器的当前值,并将索引减 1 。只要获取的索引数大于或等于 0 ,函数就会继续执行,否则退出。当所有线程都完成后,应用程序退出。

6. 原子标志

std::atomic_flag 是一种原子布尔类型。与 atomic 类的其他特化不同,它保证是无锁的,但不提供任何加载或存储操作。相反,它提供赋值运算符以及清除或测试并设置标志的函数。清除操作将标志设置为 false ,测试并设置操作将测试标志并将其设置为 true

7. 内存顺序枚举

内存顺序在 <atomic> 头文件中定义为枚举类型:

enum memory_order {
    memory_order_relaxed,
    memory_order_consume,
    memory_order_acquire,
    memory_order_release,
    memory_order_acq_rel,
    memory_order_seq_cst
};

内存顺序决定了在原子操作周围非原子内存访问的顺序,影响不同线程在执行指令时如何看待内存中的数据:
| 枚举 | 描述 |
| ---- | ---- |
| memory_order_relaxed | 宽松操作:对其他读写操作没有同步或顺序约束,仅保证此操作的原子性。 |
| memory_order_consume | 具有此内存顺序的加载操作对受影响的内存位置执行消费操作:当前线程中依赖于当前加载值的读写操作不能在该加载之前重新排序。其他线程中释放相同原子变量的依赖变量的写入在当前线程中可见。在大多数平台上,这只影响编译器优化。 |
| memory_order_acquire | 具有此内存顺序的加载操作对受影响的内存位置执行获取操作:当前线程中的读写操作不能在该加载之前重新排序。其他线程中释放相同原子变量的所有写入在当前线程中可见。 |
| memory_order_release | 具有此内存顺序的存储操作执行释放操作:当前线程中的读写操作不能在该存储之后重新排序。当前线程中的所有写入在获取相同原子变量的其他线程中可见,并且携带对原子变量依赖的写入在消费相同原子的其他线程中可见。 |
| memory_order_acq_rel | 具有此内存顺序的读 - 修改 - 写操作既是获取操作又是释放操作。当前线程中的内存读写操作不能在该存储之前或之后重新排序。其他线程中释放相同原子变量的所有写入在修改之前可见,并且修改在获取相同原子变量的其他线程中可见。 |
| memory_order_seq_cst | 具有此内存顺序的任何操作既是获取操作又是释放操作,并且存在一个单一的总顺序,所有线程以相同的顺序观察所有修改。 |

8. 不同内存顺序的应用场景
  • 宽松顺序 :宽松内存顺序不强制并发内存访问之间的顺序,仅保证原子性和修改顺序。典型用途是用于计数器的递增或递减操作。
  • 释放 - 获取顺序 :如果线程A中的原子存储标记为 memory_order_release ,线程B中从同一变量的原子加载标记为 memory_order_acquire ,那么从线程A的角度来看,在原子存储之前发生的所有内存写入(非原子和宽松原子)在线程B中都成为可见的副作用。这种操作在强有序架构(如x86、SPARC和POWER)上是自动的,而在弱有序架构(如ARM、PowerPC和Itanium)上需要使用内存屏障。典型应用包括互斥机制,如互斥锁或原子自旋锁。
  • 释放 - 消费顺序 :如果线程A中的原子存储标记为 memory_order_release ,线程B中从同一变量的原子加载标记为 memory_order_consume ,那么从线程A的角度来看,在原子存储之前依赖排序的所有内存写入(非原子和宽松原子)在那些依赖于该加载操作的线程B操作中成为可见的副作用。这种顺序在几乎所有架构上都是自动的,主要例外是(已过时的)Alpha架构。典型用例是对很少更改的数据进行读访问。从C++17开始,这种内存顺序正在修订中,暂时不建议使用 memory_order_consume
  • 顺序一致顺序 :标记为 memory_order_seq_cst 的原子操作不仅像释放/获取顺序一样对内存进行排序(一个线程中存储之前发生的所有事情在执行加载的线程中成为可见的副作用),还为所有标记为该顺序的原子操作建立了一个单一的总修改顺序。这种顺序可能在所有消费者必须以完全相同的顺序观察其他线程所做更改的情况下是必要的。在多核或多CPU系统上,这需要完整的内存屏障。由于这种复杂的设置,这种顺序比其他类型明显慢,并且要求每个原子操作都必须标记为这种内存顺序,否则顺序一致性将丢失。
9. volatile 关键字

volatile 关键字对于编写复杂多线程代码的人来说可能很熟悉。其基本用途是告诉编译器相关变量应始终从内存中加载,不做关于其值的假设,并且确保编译器不会对该变量进行任何激进的优化。然而,对于多线程应用程序,它通常是无效的,不建议使用。主要问题在于 volatile 规范没有定义多线程内存模型,这意味着该关键字的结果在不同平台、CPU甚至工具链上可能不是确定性的。在原子操作领域,不需要使用该关键字,实际上它可能也没有帮助。为了保证获取在多个CPU核心及其缓存之间共享的变量的当前版本,需要使用 atomic_compare_exchange_strong atomic_fetch_add atomic_exchange 等操作,让硬件获取正确的当前值。对于多线程代码,建议不使用 volatile 关键字,而是使用原子操作来保证正确的行为。

综上所述,原子操作在多线程编程中起着重要的作用,通过合理使用C++11的原子特性和不同的内存顺序,可以实现无锁设计并正确利用C++11内存模型。在实际编程中,应根据具体需求选择合适的原子操作和内存顺序,避免使用 volatile 关键字带来的不确定性。

原子操作:与硬件协同工作(续)

10. 原子操作总结

原子操作在多线程编程中是至关重要的,它提供了一种高效且安全的方式来处理共享数据,避免了传统锁机制带来的性能开销和死锁风险。以下是对前面内容的一个简要回顾:

  • 函数与类 :C++11 提供了丰富的原子操作支持,包括 atomic 类及其各种特化类型,以及一系列非类模板函数。这些函数和类可以方便地实现原子的加载、存储、交换、比较交换等操作。
  • 内存顺序 :内存顺序决定了原子操作周围非原子内存访问的顺序,不同的内存顺序适用于不同的场景。宽松顺序适用于计数器,释放 - 获取顺序用于互斥机制,释放 - 消费顺序用于很少更改的数据读取,顺序一致顺序用于需要严格顺序的场景。
  • 避免使用 volatile volatile 关键字在多线程编程中效果不佳,不建议使用。应使用原子操作来保证多线程环境下数据的一致性和正确性。
11. 原子操作的实际应用流程

下面通过一个 mermaid 流程图来展示原子操作在实际应用中的一般流程:

graph TD
    A[开始] --> B[包含<atomic>头文件]
    B --> C[定义原子变量]
    C --> D{选择操作类型}
    D -->|赋值| E[使用operator=或store函数]
    D -->|读取| F[使用load函数或operator T]
    D -->|交换| G[使用exchange函数]
    D -->|比较交换| H[使用compare_exchange_weak或compare_exchange_strong]
    D -->|算术操作| I[使用fetch_add、fetch_sub等函数]
    E --> J[设置内存顺序]
    F --> J
    G --> J
    H --> J
    I --> J
    J --> K[执行操作]
    K --> L{是否完成任务}
    L -->|否| D
    L -->|是| M[结束]
12. 不同内存顺序的选择建议

在实际编程中,选择合适的内存顺序对于性能和正确性至关重要。以下是一个简单的选择建议列表:
- 宽松顺序( memory_order_relaxed
- 当只需要保证操作的原子性,而不需要任何同步或顺序约束时使用。
- 例如,在计数器的递增或递减操作中,不同线程对计数器的操作顺序不影响最终结果。
- 释放 - 获取顺序( memory_order_release memory_order_acquire
- 当需要保证一个线程的写操作对另一个线程的读操作可见时使用。
- 常用于互斥机制,如互斥锁或原子自旋锁。
- 释放 - 消费顺序( memory_order_release memory_order_consume
- 当只需要保证依赖关系的内存可见性时使用。
- 适用于对很少更改的数据进行读访问,但从 C++17 开始不建议使用。
- 顺序一致顺序( memory_order_seq_cst
- 当需要保证所有线程以相同的顺序观察所有原子操作的修改时使用。
- 但由于其性能开销较大,应谨慎使用。

13. 原子操作的性能考虑

原子操作虽然避免了传统锁机制的一些问题,但也有其自身的性能开销。以下是一些影响原子操作性能的因素:
- 内存顺序 :顺序一致顺序( memory_order_seq_cst )需要完整的内存屏障,性能开销最大;宽松顺序( memory_order_relaxed )性能最好。
- 硬件架构 :不同的硬件架构对原子操作的支持不同。强有序架构(如 x86)在释放 - 获取顺序上性能较好,而弱有序架构(如 ARM)可能需要额外的内存屏障。
- 缓存一致性 :在多核或多 CPU 系统中,原子操作可能会导致缓存一致性问题,影响性能。

为了提高原子操作的性能,可以采取以下措施:
- 尽量使用宽松的内存顺序,减少不必要的同步开销。
- 根据硬件架构选择合适的原子操作和内存顺序。
- 合理设计数据结构,减少原子操作的竞争。

14. 总结与展望

原子操作是多线程编程中不可或缺的一部分,通过合理使用 C++11 的原子特性和不同的内存顺序,可以实现高效、安全的多线程程序。在实际编程中,应根据具体需求选择合适的原子操作和内存顺序,避免使用 volatile 关键字带来的不确定性。

随着硬件技术的不断发展,原子操作的性能和功能也将不断提升。未来,可能会出现更多高效的原子操作指令和更灵活的内存模型,为多线程编程带来更多的可能性。同时,对于原子操作的研究和应用也将不断深入,为解决复杂的多线程问题提供更好的方案。

总之,掌握原子操作的原理和应用,对于编写高质量的多线程代码具有重要意义。希望本文能够帮助读者更好地理解和应用原子操作,在多线程编程中取得更好的效果。

评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符  | 博主筛选后可见
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值