「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
2
std::atomic_flag flag = ATOMIC_FLAG_INIT; // 初始化为 0
bool pre_val = flag.test_and_set(); // pre_val == false

在上面的示例中,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
2
3
std::atomic_flag flag = ATOMIC_FLAG_INIT;
flag.test_and_set(); // flag 设置为 true
flag.clear(); // flag 设置为 false

这里先将 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
#include <iostream>
#include <thread>
#include <atomic>
#include <vector>

class spinlock_mutex{
std::atomic_flag flag; // 定义一个 atomic::flag 对象
public:
spinlock_mutex(): flag(ATOMIC_FLAG_INIT) {};
void lock(){
while(flag.test_and_set()); // 循环检查
}
void unlock(){
flag.clear(); // 清除 flag 状态
}
};

int count; // 共享数据变量
spinlock_mutex mtx;

void foo(){
mtx.lock();
count ++ ;
mtx.unlock();
}

int main(){
std::vector<std::thread> threads(10);
for(auto& t : threads) t = std::thread(foo);
for(auto& t : threads) t.join();

std::cout << "count = " << count << "\n";

return 0;
}

运行结果如下:

1
count = 10

4 std::atomic 简单操作

4.1 变量声明和初始化

声明和初始化原子类型变量的方法如下:

1
2
std::atomic<int> atm(0);
// std::atomic<int> atm = 0; // 也可以

当然也可以先声明,再初始化:

1
2
std::atomic<int> atm;
atm = 0;

在上面的示例中,声明了一个 std::atomic<int> 类型的原子类型变量。初始值为 0。

std::atomic 类型禁止使用拷贝构造函数和拷贝赋值运算符。原子操作的关键在于其不可分割性和原子性,如果允许随意的拷贝操作,就很难保证原子变量在整个生命周期内的操作安全性:

1
2
3
4
std::atomic<int> atm1(10);
std::atomic<int> atm2 = a1; // ERROR,试图使用拷贝构造函数
std::atomic<int> atm3;
atm3 = atm1; // ERROR,试图使用拷贝赋值运算符

4.2 load()

std::atomic::load()std::atomic 模板类提供的一个成员函数,用于原子地 读取 std::atomic 类型变量的值。

这个操作(原子操作)可以确保读取到的值是完整且准确的,不会出现读取到正在被其他线程修改的中间值的情况。

此处先不考虑内存顺序等的影响,这个之后再在后续新文章中写,本文章只写一些简单的东西。

读取 std::atomic 变量的值方法如下:

1
2
std::atomic<int> atm1(1);
int val = atm1.load(); // 读取 atomic 变量的值

4.3 store()

std::atomic::store()std::atomic 模板类提供的一个成员函数,用于原子地将一个值 写入 std::atomic 类型的变量。

std::atomic::store() 基本上就相当于用 operator= 进行赋值,效果是一样的。

此处也先不考虑内存顺序等的影响,这个之后再在后续新文章中写。

存储 std::atomic 变量的值方法如下:

1
2
3
4
std::atomic<int> atm1;
atm1.store(2);
atm1 = 2; // 和上面效果一样
int val = atm1.load(); // val = 2

4.4 exchange()

std::atomic::exchange() 是std::atomic模板类中的一个成员函数。它的主要功能是原子地将 std::atomic 变量的当前值与一个新的值进行 交换

此处也先不考虑内存顺序等的影响,这个之后再在后续新文章中写。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#include <iostream>
#include <thread>
#include <atomic>

std::atomic<int> atm(1);

void foo() {
int old_val = atm.exchange(2);
std::cout << "Old value: " << old_val << "\n"; // 1
std::cout << "New value: " << atm.load() << "\n"; // 2
}

int main() {
std::thread t(foo);
t.join();

return 0;
}

在上面的代码中,atm 的初始值为 1。在线程函数 foo() 中通过 exchange() 操作将 atm 的值与 2 进行交换。

exchange() 函数会返回 atm 原来的初始值 1,并将 atm 的值设置为交换的 2

因此最终,old_val 的值为 1atm 存储的值为 2

运行结果如下:

1
2
Old value: 1
New value: 2

5 多线程 std::atomic 简单使用方法

5.1 互斥锁确保数据一致

通常情况下,我们可以通过互斥锁来确保数据的一致性。如果对数据的操作比较复杂,起始采用互斥锁是不错的选择。

我们都知道加锁有一定的开销。如果对数据的操作很简单,例如仅仅是自加、自减的操作,那么采用互斥锁就显得效率没那么高了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
#include <iostream>
#include <thread>
#include <mutex>

int cnt = 0;
std::mutex mtx;

void foo_use_lock(){
for(int i = 1; i <= 1000000; i ++ ){
// 加锁,若不加锁,最终可能输出一个中间值
std::lock_guard<std::mutex> lck(mtx);
cnt ++ ;
cnt += 2;
}
}

int main(){
std::thread t1(foo_use_lock);
std::thread t2(foo_use_lock);
t1.join();
t2.join();

std::cout << cnt << "\n"; // 6000000

return 0;
}

在上面的代码中,我们启动了两个子线程都对共享数据 cnt 进行写入操作,执行的线程函数中将循环 1000000 次对数据先后进行 +++2 的操作。

为了保证数据的一致性,我们可以考虑在每次循环时 加锁,确保多线程对共享数据变量的互斥访问和修改。

如果不加锁,那么最终输出的可能是一个中间值;通过加锁,我们可以确保最终的答案为 6000000

5.2 原子操作确保数据一致

互斥锁确保数据一致 的示例中,我们通过互斥锁来确保数据的一致性。我们的数据操作仅仅涉及自增(先后一次 ++,一次 +2),却进行了多达 2000000 次加锁的操作,这毫无疑问开销是比较大的。

对于简单的数据操作,我们可以用 std::atomic 原子操作 来确保数据的一致性(原子操作不会被打断),同时又能避免频繁地加锁,显著提升效率。

可以用于 原子操作 的数据运算大致包括:

  • 自增 ++自减 --

    1
    2
    3
    std::atomic<int> atm(0);
    atm ++;
    atm --;
  • 加法 +=减法 -=(局限于对自身操作)

    需要注意的是,假设有一个变量 xx = x + 1 不是原子操作x = x + 1 其实分为两步:

    第一步是从内存中读取变量 x 的值。这是一个内存读取(Load)操作,将 x 的值加载到处理器的寄存器中。

    第二步才是进行 + 1 的加法操作。

    1
    2
    3
    std::atomic<int> atm(0);
    atm += 3;
    atm -= 2;
  • 位运算 &=|=^= (局限于对自身操作)

    1
    2
    3
    4
    std::atomic<int> atm(10);
    atm &= 3;
    atm |= 4;
    atm ^= 5;

我们可以通过 std::atomic 改写 互斥锁确保数据一致 的示例如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
#include <iostream>
#include <thread>
#include <mutex>
#include <atomic>

std::atomic<int> at_cnt = 0;

void foo_use_atomic(){
for(int i = 1; i <= 1000000; i ++ ){
at_cnt ++ ; // 原子操作不会被打断,无需加锁
at_cnt += 2;
// at_cnt = at_cnt + 1; // 不是原子操作
}
}

int main(){
std::thread t1(foo_use_atomic);
std::thread t2(foo_use_atomic);
t1.join();
t2.join();

std::cout << at_cnt << "\n";

return 0;
}

在上面的代码中,我们通过定义共享数据变量为 std::atomic<int> 类型的原子变量确保了数据的一致性。原子类型可以看作是无锁类型的互斥访问,但是所支持的操作也必须是原子的,具有一定的局限性。

最终输出的答案也可以确保为 6000000

总的来说,如果一个代码中某个数据所涉及的数据操作都是类似于自加、自减等 不可分割简单操作,那么通常可以采用 原子操作 进行优化;而其他的数据所涉及的数据 操作是比较复杂 的,那么可以考虑采用 互斥锁 来确保数据的一致性。

参考

  1. std::atomic

  2. 第5章 C++内存模型和原子类型操作


「C++ 多线程」std::atomic 简单操作
https://marisamagic.github.io/2025/01/06/20250106/
作者
MarisaMagic
发布于
2025年1月6日
许可协议