浅析C++ atomic

这篇具有很好参考价值的文章主要介绍了浅析C++ atomic。希望对大家有所帮助。如果存在错误或未考虑完全的地方,请大家不吝赐教,您也可以点击"举报违法"按钮提交疑问。

早在C++11就在STL中引入了原子操作支持了。大部分时候,我使用C++11的atomic仅仅是为了原子地操作特定的一个变量,比如loadstorefetch_add等等。然而实际上,C++11的原子操作带着的memory order还能起到memory barrier的作用。本文会从头介绍C++11原子变量的用法,使用的注意事项以及一些原理,原理部分会涉及少量的计算机体系结构的知识,主要与CPU的缓存相关。

原子操作

原子性

原子操作指的是要么处于已完成的状态,要么处于初始状态,而不存在中间状态的操作。例如,假设下面的函数满足原子性(它实际上不满足原子性,但我们假设它满足):

int value = 0;
void atomic_function() {
    for (int i = 0; i < 100; ++i)
        value += 1;
}

我们在线程A调用atomic_function(),然后我们在线程B观察value。因为它满足原子性,所以我们在线程B观察到的value要么是0(atomic_function()没有执行,value的初始状态),要么是100(atomic_function()执行完毕),而不会观察到1,2,50等中间状态。

原子性这个性质并不仅仅限定于针对内存的操作,比如关系型数据库的事务也是满足原子性的(这里点名批评MySQL)。

数据竞争

多线程访问同一个变量时会出现数据竞争(data race),数据竞争会导致结果变得不确定:

int value = 0;
void modify_value() {
    value += 1;
}

假设我们在A和B两个线程同时执行modify_value(),那么可能会出现这样的执行顺序:

+----------------------------+----------------------------+
|          thread A          |          thread B          |
+----------------------------+----------------------------+
| int tmp = value; // 0      | int tmp = value; // 0      |
| tmp += 1;        // 1      | tmp += 1;        // 1      |
| value = tmp;     // 1      | value = tmp;     // 1      |
+----------------------------+----------------------------+

如果以这样的顺序执行,那么value最终的结果就是1。之所以会出现这种情况,是因为CPU并不会在每次访问变量时都去访问内存,而是优先访问寄存器与高速缓存。对于具有多个核心的CPU来说,CPU的每个核心通常都有独立的寄存器以、L1缓存和L2缓存,L3缓存通常是多个核心共享的。当变量存在与CPU核心的寄存器或L1、L2缓存中时,就可能出现这种情况。

而如果两个线程按照这样的顺序执行:

+----------------------------+----------------------------+
|          thread A          |          thread B          |
+----------------------------+----------------------------+
| int tmp = value; // 0      |                            |
| tmp += 1;        // 1      |                            |
| value = tmp;     // 1      |                            |
|                            | int tmp = value; // 1      |
|                            | tmp += 1;        // 2      |
|                            | value = tmp;     // 2      |
+----------------------------+----------------------------+

那么value最终的结果就是2,也就是正确的执行结果。原子操作能够保证多个线程不会同时操作同一个变量。

std::atomic

C++11提供了一个模板类std::atomic<T>,同时还预定义了一些常用的类型,比如std::atomic_int等。这个模板类提供了各种原子访问操作,比如load()store()等存取操作、compare_exchange等CAS操作、fetch_add等加减和位运算。这些方法的使用本身很简单,本文不再详述。这里想要讨论的是以下几个问题:

  1. 既然std::atomic<T>是个模板类型,那么是否所有的类型都可以被原子地访问?
  2. std::atomic<T>支持哪些原子操作?它的所有操作都满足原子性吗?
  3. atomic是否等价于无锁?

类型限制

正如上文所述,std::atomic<T>的原子操作依赖CPU的指令来实现。因此不难想到,std::atomic<T>的原子操作只能用于纯粹的数据。“纯粹的数据”指的是类型T必须可平凡拷贝(trivially copyable),即满足

static_assert(std::is_trivially_copyable<T>::value, "T is not trivially copyable.");

“可平凡拷贝”这个说法比较抽象。基本上,只要一个类型满足这几个条件,它就可平凡拷贝:

  1. 可以用memcpy拷贝
  2. 没有虚函数
  3. 构造函数noexcept

比如,这些都是典型的可平凡拷贝的类型:

int i;                       // trivially copyable.
double d;                    // trivially copyable.
struct { long a; int b; } s; // trivially copyable.
char c[256];                 // trivially copyable.

这些是典型的不可平凡拷贝的类型:

std::string s;      // not trivially copyable.
std::vector<int> v; // not trivially copyable.

C语言的各种类型,即Plain Old Data(POD)就是典型的可平凡拷贝类型(C++标准已经弃用POD的说法,改用平凡类型等更具体的名称)。

原子操作

从cppreference.com可以很容易地归纳出std::atomic<T>支持的各种原子操作,比如加减法、位运算等。需要注意的是,以下几种运算是不支持原子操作的:

  • (有符号和无符号)整型的乘除法
  • 浮点数的加减乘除运算

原子变量在使用的时候有各种陷阱,比如以下代码:

std::atomic_int x{1};
x = 2 * x;

这段代码能够正常地编译通过,但它可能并不会像你预期的那样工作。你可能期望它能够原子地完成乘法与赋值,但这段代码实际上是这样工作的:

std::atomic_int x{1};
int tmp = x.load();
tmp = tmp * 2;
x.store(tmp);

它并不是一次完成的,而是分成了一次原子读取、一次乘法以及一次原子写入。std::atomic的数学运算有很多类似的陷阱,原因与刚刚所述的类似:

std::atomic_int x{1};
x += 1;    // atomic operation.
x = x + 1; // not atomic!

正因如此,我个人更建议使用std::atomic提供的函数,而不是数学运算符。比如使用x.fetch_add(1)来代替x += 1,因为一次方法调用很明确地就是一次原子操作。

atomic与无锁

std::atomic<T>一定是无锁的吗?其实只要你花一点时间去翻一下cppreference.com就能得到答案:“不!”,因为std::atomic<T>提供了一个方法叫is_lock_free

考虑以下几个结构体:

struct A { long x; };
struct B { long x; long y; };
struct C { char s[1024]; };

A应当是无锁的,因为它显然等价于long。C应该不是无锁的,因为它实在是太大了,目前没有寄存器能存下它。至于B我们就很难直接推断出来了。

对于x86架构的CPU,结构体B应当是无锁的,它刚刚好可以原子地使用MMX寄存器(64bit)处理。但如果它再大一点(比如塞一个int进去),它就不能无锁地处理了。

原子操作究竟是否无锁与CPU的关系很大。如果CPU提供了丰富的用于无锁处理的指令与寄存器,则能够无锁执行的操作就会多一些,反之则少一些。除此之外,原子操作能否无锁地执行还与内存对齐有关。正因如此,is_lock_free()才会是一个运行时方法,而没有被标记为constexpr

内存屏障与memory order

在C++11之前,C++没有提供统一的跨平台的内存屏障。而从C++11开始,std::atomic的memory order就能够起到内存屏障的作用了。在介绍memory order之前,首先介绍以下CPU乱序执行:

CPU乱序执行

为了节省时间并提高程序执行的效率,CPU在执行程序的时候可能会打乱指令执行顺序。比如,考虑下面这一段汇编指令:

1   mov (%r10), %r11
2   add %r11,   %rdx
3   mov %rcx,   %rax

这段汇编指令所做的事情很简单。指令1从寄存器%r10指向的地址取出数据,存入寄存器%r11;指令2使%r11%rdx进行整数加法,加法的结果存入%rdx;指令3将寄存器%rcx的值拷贝一份放入寄存器%rax。使用类似C语言的写法就是

r11 = *r10;
rdx += r11;
rax = rcx;

指令1需要从内存取数据。相比于CPU的运算速度,从内存读取数据是很慢的,而第二条指令又依赖于第一条指令的结果,因此这段指令的执行顺序可能会被CPU重排为1 -> 3 -> 2

Memory Order

C++11提供了6中memory order,分别是relaxed、consume、acquire、release、acq_rel、seq_cst。我们首先介绍relaxed、acqure与release这三种,另外三种可以在这三种的基础上进行推广。

std::memory_order_relaxed是约束力最低的memory order,它只保证对std::atomic<T>变量本身的访问是原子的,并不能起到内存屏障的作用。值得一提的是,由于x86架构下普通访存操作本身就满足std::memory_order_relaxed,因此有很多人发现使用volatile也具有原子操作的特性,这种用法是错误的。volatile仅能够保证编译器不会打乱内存读写相关指令的顺序,而不能约束CPU的访存行为,也不能约束CPU的乱序执行。

std::memory_order_acquire通常与std::memory_order_release成对使用。它除了保证原子访存之外,还保证排在原子操作之后的读写指令不会由于CPU乱序执行而在原子操作之前被执行。以下面的伪指令为例:

load A
store B
inst C
atomic operation
store D
load E
load F

指令D、E、F不会由于CPU乱序执行而在atomic operation之前被执行,但允许A、B、C被放到atomic operation之后执行。

std::memory_order_releasestd::memory_order_acquire刚好相反。它保证排在原子操作之前的读写指令不会被排到原子操作之后执行。还是以上面的伪指令为例,指令A、B不会被排到atomic operation之后执行,但允许D、E、F被排到atomic operation之前执行。

通常,我们会使用release store配合acquire load来实现内存屏障的功能。比如:

+----------------------------+----------------------------+
|          thread A          |          thread B          |
+----------------------------+----------------------------+
| store A                    |                            |
| inst B                     |                            |
| release store X            |                            |
| store D                    | acquire load X             |
|                            | load A // valid            |
|                            | load D // maybe invalid    |
+----------------------------+----------------------------+

由于在线程A中,store A一定会在release store X之前完成,而在线程B中,load A一定在acquire load X之后才会执行,因此Acquire-Release在这里能够起到内存屏障的作用,从而保证线程A的写入对于线程B可见。

需要注意的是,Acquire-Release只对于使用release store和acquire load操作的两个线程起到内存屏障的作用。如果需要内存屏障对所有线程起作用,则应当使用std::memory_order_seq_cststd::memory_order_seq_cst保证会刷新所有CPU核心的cache line。

std::memory_order_acq_rel相当于std::memory_order_acquire + std::memory_order_release,即同时保证原子操作前面的访存指令不会被重排到后面,也保证原子操作之后的访存指令不会被重排到前面。

std::memory_order_consumestd::memory_order_acquire类似,但它的约束比std::memory_order_acquire要小。std::memory_order_acquire保证atomic load之后的所有访存操作都不会被排到atomic load之前,但std::memory_order_consume只保证与原子变量存在依赖关系的访存操作不会被排到atomic load之前。

性能

CPU Cache Line

CPU的缓存是存在“行”的,即CPU每次会将一段连续的数据加载到缓存中。要访问内存时,CPU会首先查找要访问的内存所在的这一行是否已经被加载到缓存中,如果命中则直接从缓存中的这一行取出对应的数据。更详细的内容请参考计算机组成原理,这里我们只需要知道CPU的缓存会以“行”为单位进行加载和释放即可。

Cache line会对原子操作的性能产生影响,具体表现为同时原子访问同一个Cache Line中的元素时,二者并不会同时进行,其中某一个访存会等待另一个原子访存完成。详细的内容请参考CppCon2017 C++ atomics, from basic to advanced。

算法

通常我们使用无锁数据结构时,是因为它快。但实际上算法的影响往往要大得多。来看一下CppCon2017给出的例子:

std::atomic_size_t a{0};
void do_work(std::size_t n) {
    for (std::size_t i = 0; i < n; ++i)
        a.fetch_add(1, std::memory_order_relaxed);
}

std::size_t b{0};
std::mutex m;
void do_work2(std::size_t n) {
    std::size_t result = 0;
    for (std::size_t i = 0; i < n; ++i)
        result += 1;
    std::lock_guard<std::mutex> lock(m);
    b += result;
}

随着线程数量的增加,do_work2相对于do_work1的优势越来越明显。显然,减少线程之间的竞争要比采用无锁操作要快得多。

结语

本文是对C++11引入的原子操作的使用简介,同时也是我观看CppCon2017 C++ atomics, from basic to advanced的一篇笔记。在本文中我略去了compare_exchange相关的内容,因为。。。我懒得写了,实际上compare_exchange_strongcompare_exchange_weak有不少值得记录的内容。compare_exchange_weak的“假失败”与CPU的设计有关,如果好奇的话就去看CppCon的演讲吧。文章来源地址https://www.toymoban.com/news/detail-710457.html

参考资料

  • CppCon2017 C++ atomics, from basic to advanced
  • cppreference.com

到了这里,关于浅析C++ atomic的文章就介绍完了。如果您还想了解更多内容,请在右上角搜索TOY模板网以前的文章或继续浏览下面的相关文章,希望大家以后多多支持TOY模板网!

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处: 如若内容造成侵权/违法违规/事实不符,请点击违法举报进行投诉反馈,一经查实,立即删除!

领支付宝红包 赞助服务器费用

相关文章

  • C++ 中的原子变量(std::atomic)使用指南

    原子变量( std::atomic )是C++中用于多线程编程的强大工具之一。它们提供了一种线程安全的方式来访问和修改共享数据,而无需使用显式的互斥锁。本文将介绍 std::atomic 的基本概念、使用方法、常见应用场景以及示例代码,适合入门级读者。 原子变量是一种特殊的数据类型

    2024年01月21日
    浏览(43)
  • Java的Atomic原子类

    Java SDK 并发包里提供了丰富的原子类,我们可以将其分为五个类别,这五个类别提供的方法基本上是相似的,并且每个类别都有若干原子类。 对基本数据类型的变量值进行原子更新; 对对象变量的指向进行原子更新; 对数组里面的的元素进行原子更新; 原子化的对象属性更

    2024年02月06日
    浏览(37)
  • 【JUC基础】10. Atomic原子类

    Atomic英译为原子的。原子结构通常称为不可分割的最小单位。而在JUC中,java.util.concurrent.atomic 包是 Java 并发库中的一个包,提供了原子操作的支持。它包含了一些原子类,用于在多线程环境下进行线程安全的原子操作。使用原子类可以避免使用锁和同步机制,从而减少了线程

    2024年02月06日
    浏览(40)
  • 6.3 C++11 原子操作与原子类型

    在C++中,一个全局数据在多个线程中被同时使用时,如果不加任何处理,则会出现数据同步的问题。 上述例子中test函数对全局变量val进行累加,并在thread1和thread2两个线程中分别处理test函数。得到的结果如下: val的值并不是期望的20000000,这是因为val++操作不是原子操作导致

    2024年02月04日
    浏览(27)
  • 面试专题:java多线程(3)---关于 Atomic 原子类

    1.介绍一下Atomic 原子类Atomic     翻译成中文是原子的意思。在化学上,我们知道原子是构成一般物质的最小单位,在化学反应中是不可分割的。在我们这里  Atomic   是指一个操作是不可中断的。即使是在多个线程一起执行的时候,一个操作一旦开始,就不会被其他线程干扰

    2024年02月07日
    浏览(58)
  • 原子范数 Atomic norm最小化: 简单的Matlab例程

    基于 压缩感知的尽头: 原子范数最小化 中的原子范数最小化算法, 笔者做了一些matlab的仿真, 作为简单的例程,希望帮助大家进一步理解算法和自定义的拓展。 由于凸问题的求解需要使用 CVX, 因此需要读者先自行安装好 matlab 的 CVX包。 假设接收天线有 64 64 6 4 根, 有 3

    2023年04月08日
    浏览(94)
  • C++11原子变量:线程安全、无锁操作的实例解析

      在 C++11 中,原子变量( std::atomic )提供了一种线程安全的方式来操作共享变量。下面是一个简单的例子,演示了C++11原子变量的用法。 原子性操作:  原子变量提供了原子性操作,避免了多线程同时访问共享变量时的竞争条件。 无锁:  使用原子变量的操作是无锁的,因

    2024年01月20日
    浏览(45)
  • C++原子操作与内存序 1

    这个程序只是简单的通过两个线程对同一个变量进行累加10000次,正常不管线程执行的先后顺序,结果都应该是20000才对,可实际输出结果如图所示,程序的输出3次的结果都不一样,不一定是预期的20000; 对于+1操作,具体执行可以分为3个操作,如下图所示: 可以看出问题发

    2024年04月08日
    浏览(27)
  • C++学习笔记——C++ 新标准(C++11、C++14、C++17)引入的重要特性

    目录 1、简介 2.自动类型推导和初始化 示例代码 3.智能指针 示例代码 4.Lambda 表达式 示例代码 5.右值引用和移动语义 示例代码 6.并发编程支持 示例代码 7.其他特性 八、案例:实现一个简单的并发下载器 上一篇文章:     C++标准模板库(STL)是C++的一个重要组成部分,它提

    2024年01月19日
    浏览(37)
  • Win11系统提示缺少msvcp140_atomic_wait.dll文件的解决办法

    其实很多用户玩单机游戏或者安装软件的时候就出现过这种问题,如果是新手第一时间会认为是软件或游戏出错了,其实并不是这样,其主要原因就是你电脑系统的该dll文件丢失了或者损坏了,这时你只需下载这个msvcp140_atomic_wait.dll文件进行安装(前提是找到适合的版本),当

    2024年02月04日
    浏览(143)

觉得文章有用就打赏一下文章作者

支付宝扫一扫打赏

博客赞助

微信扫一扫打赏

请作者喝杯咖啡吧~博客赞助

支付宝扫一扫领取红包,优惠每天领

二维码1

领取红包

二维码2

领红包