「C++ 多线程」std::atomic 简单操作
文章大图来源:pixiv_id=96078807
1 原子操作和原子类型
1.1 原子操作
原子操作 是一类 不可分割 的操作,当这样操作在任意线程中进行一半的时候,你是不能查看的;它的状态要么是完成,要么是未完成。
如果一个线程执行了一个数据存储操作,其他线程读取这个数据时,要么是存储之前状态的值,要么就是存储完成后的值。
与其不同的是,非原子操作 可能会被视为由一个线程完成一半的操作。如果一个线程执行的是一个存储操作,其他线程读取数据时可能取出的既不是存储前的值,也可能不是已存储的值(可能为一个中间状态的值)。
1.2 原子类型
原子类型是一种特殊的数据类型,在多线程环境下,对原子类型变量的操作不可分割。
在 C++ 多线程中,引入了 std::atomic
系列类型,提供了原子类型以及原子操作,是构建多线程安全的重要工具之一。
2 std::atomic 基本概念
std::atomic
是 C++11 引入的原子类型,是一个模板类,用于创建 原子类型 的变量。它提供了 原子操作,确保在多线程环境下对这些变量的操作是线程安全的,不会出现数据竞争。
std::atomic
中有最基本的原子类型 std::atomic_flag
,也有很多通过类模板应用于更广泛数据类型的原子类型,例如 std::atomic<int>
、std::atomic<bool>
、std::atomic<long long>
等。
std::atomic
不可以调用拷贝构造函数和拷贝赋值运算符。
3 std::atomic_flag
3.1 基本概念
std::atomic_flag
是 C++11 引入的原子类型,用于实现简单的原子操作。
std::atomic_flag
是最基本的原子类型,提供了一种无锁的原子操作机制。std::atomic_flag
对象可以处于两种状态:设置状态(true)和清除状态(false)。
定义一个 std::atomic_flag
对象需要通过 ATOMIC_FLAG_INIT
初始化(源码中 ATOMIC_FLAG_INIT
实际被定义为 0)。
3.2 test_and_set()
std::atomic_flag::test_and_set()
用于将 std::atomic_flag
对象的值设置为 true
,并且 返回该对象之前的值。
一个简单的操作示例如下:
1 |
|
在上面的示例中,flag
初始化为清除状态(通过 ATOMIC_FLAG_INIT
初始化,值为 false
)。
当 test_and_set()
被调用,flag
被设置为 true
,并且 flag.test_and_set()
返回先前的值(此处为 false
),用 pre_val
存储这个先前的值(为 false
)。
3.3 clear()
std::atomic_flag::clear()
用于将 std::atomic_flag
对象的值设置为 false
。
一个简单的操作示例如下:
1 |
|
这里先将 flag
设置为 true
,然后通过 clear
函数将其设置回 false
。
3.4 std::atomic_flag 实现自旋锁
自旋锁(Spin Lock)是一种锁机制,当一个线程尝试获取锁但锁已经被其他线程持有时,它会 不断地检查锁是否可用,而不是进入阻塞状态。
std::atomic_flag
可以用于实现简单的自旋锁。主要的思路如下:
定义一个 std::atomic_flag
对象 flag
,每个线程不断尝试通过调用 test_and_set()
函数循环检查先前的 flag
值,直到 flag
值为 false
:
- 若
flag
先前值为false
,那么当前可以进一步进行操作(“锁”可用); - 否则,继续循环检查。
通过调用 test_and_set()
函数循环检查巧妙之处在于,可以同时获取先前的值并将值设置为 true
。如果先前的值为 false
,“锁”可用,并且会把值设置为 true
,然后进一步进行操作;如果先前的值为 true
,“锁”不可用,而且还是会把值设置为 true
,还是要继续进行循环检查。
进行一系列操作完之后,调用 clear()
函数将 flag
状态清除(设置为 false
),就好像释放 “锁” 一样。
实现代码如下:
1 |
|
运行结果如下:
1 |
|
4 std::atomic 简单操作
4.1 变量声明和初始化
声明和初始化原子类型变量的方法如下:
1 |
|
当然也可以先声明,再初始化:
1 |
|
在上面的示例中,声明了一个 std::atomic<int>
类型的原子类型变量。初始值为 0。
std::atomic
类型禁止使用拷贝构造函数和拷贝赋值运算符。原子操作的关键在于其不可分割性和原子性,如果允许随意的拷贝操作,就很难保证原子变量在整个生命周期内的操作安全性:
1 |
|
4.2 load()
std::atomic::load()
是 std::atomic
模板类提供的一个成员函数,用于原子地 读取 std::atomic
类型变量的值。
这个操作(原子操作)可以确保读取到的值是完整且准确的,不会出现读取到正在被其他线程修改的中间值的情况。
此处先不考虑内存顺序等的影响,这个之后再在后续新文章中写,本文章只写一些简单的东西。
读取 std::atomic
变量的值方法如下:
1 |
|
4.3 store()
std::atomic::store()
是 std::atomic
模板类提供的一个成员函数,用于原子地将一个值 写入 std::atomic
类型的变量。
std::atomic::store()
基本上就相当于用 operator=
进行赋值,效果是一样的。
此处也先不考虑内存顺序等的影响,这个之后再在后续新文章中写。
存储 std::atomic
变量的值方法如下:
1 |
|
4.4 exchange()
std::atomic::exchange()
是std::atomic模板类中的一个成员函数。它的主要功能是原子地将 std::atomic
变量的当前值与一个新的值进行 交换。
此处也先不考虑内存顺序等的影响,这个之后再在后续新文章中写。
1 |
|
在上面的代码中,atm
的初始值为 1
。在线程函数 foo()
中通过 exchange()
操作将 atm
的值与 2
进行交换。
exchange()
函数会返回 atm
原来的初始值 1
,并将 atm
的值设置为交换的 2
。
因此最终,old_val
的值为 1
,atm
存储的值为 2
。
运行结果如下:
1 |
|
5 多线程 std::atomic 简单使用方法
5.1 互斥锁确保数据一致
通常情况下,我们可以通过互斥锁来确保数据的一致性。如果对数据的操作比较复杂,起始采用互斥锁是不错的选择。
我们都知道加锁有一定的开销。如果对数据的操作很简单,例如仅仅是自加、自减的操作,那么采用互斥锁就显得效率没那么高了。
1 |
|
在上面的代码中,我们启动了两个子线程都对共享数据 cnt
进行写入操作,执行的线程函数中将循环 1000000
次对数据先后进行 ++
和 +2
的操作。
为了保证数据的一致性,我们可以考虑在每次循环时 加锁,确保多线程对共享数据变量的互斥访问和修改。
如果不加锁,那么最终输出的可能是一个中间值;通过加锁,我们可以确保最终的答案为 6000000
。
5.2 原子操作确保数据一致
在 互斥锁确保数据一致 的示例中,我们通过互斥锁来确保数据的一致性。我们的数据操作仅仅涉及自增(先后一次 ++
,一次 +2
),却进行了多达 2000000
次加锁的操作,这毫无疑问开销是比较大的。
对于简单的数据操作,我们可以用 std::atomic
原子操作 来确保数据的一致性(原子操作不会被打断),同时又能避免频繁地加锁,显著提升效率。
可以用于 原子操作 的数据运算大致包括:
-
自增
++
和 自减--
1
2
3std::atomic<int> atm(0);
atm ++;
atm --; -
加法
+=
和 减法-=
(局限于对自身操作)需要注意的是,假设有一个变量
x
,x = x + 1
不是原子操作。x = x + 1
其实分为两步:第一步是从内存中读取变量 x 的值。这是一个内存读取(Load)操作,将 x 的值加载到处理器的寄存器中。
第二步才是进行
+ 1
的加法操作。1
2
3std::atomic<int> atm(0);
atm += 3;
atm -= 2; -
位运算
&=
、|=
、^=
(局限于对自身操作)1
2
3
4std::atomic<int> atm(10);
atm &= 3;
atm |= 4;
atm ^= 5;
我们可以通过 std::atomic
改写 互斥锁确保数据一致 的示例如下:
1 |
|
在上面的代码中,我们通过定义共享数据变量为 std::atomic<int>
类型的原子变量确保了数据的一致性。原子类型可以看作是无锁类型的互斥访问,但是所支持的操作也必须是原子的,具有一定的局限性。
最终输出的答案也可以确保为 6000000
。
总的来说,如果一个代码中某个数据所涉及的数据操作都是类似于自加、自减等 不可分割 的 简单操作,那么通常可以采用 原子操作 进行优化;而其他的数据所涉及的数据 操作是比较复杂 的,那么可以考虑采用 互斥锁 来确保数据的一致性。