一. 背景
在多线程编程中锁,条件变量都是多线程编程里面的内容,原子变量也是里面重要的补充。我们程序运行时会乱序执行,并不是按照我们程序书写的代码顺序那样去执行。为什么会出现这样的情况呢,主要是因为编译器优化,编译器为了执行效率的提高,会将代码执行顺序重新排列。
这也就给我们多线程编程带来了挑战,特别是现在是CPU多核的时代,当多个线程并行执行的时候,如果没有恰当的同步机制,不同CPU核看到的线程间共享数据是有差别的。在单核情况下,多线程也会遇到此问题。在单核一个线程下,程序执行语句也会重新排序,只是保证最后执行的结果不会改变。
同时CPU核心基本上都有缓存加速机制,这样更增加了多线程编程的难度。
传统上的多线程解决方法是加锁,如mutex等,但是存在下面弊端:
1. 性能损失,加锁/解锁操作对于一些简单语句的保护是比较耗性能的。
2. 死锁风险,编程中,如果锁中再套相同的锁,会出现死锁,代码复杂情况下,不好维护。
如果上面两种情况,大家觉得能够承担,就尽量用锁的方法多线程编程解决共享数据不一致问题。
而原子变量/操作的方法是在上述情况不能承担的情况下考虑使用的方法,这个方法编程会比较复杂。
二. 原子变量/操作的定义
原子变量/操作,顾名思义就是不可再分的变量/操作,即引入一些变量或者函数,保证这些变量或者函数所对应的操作语句是不可再分的,在多个线程里面,这些变量是都可见的,没有缓存机制影响,并且代码在内存中执行顺序是得到保证的。
其表达形式如下:
#include <atomic>
template <typename T>
std::atomic<T> myVar();
2.1 原子变量的操作
- load() : 从原子变量里面读出原子变量的值
- store() : 将值写入到原子变量里面
- fetch_add() 和 fetch_sub : 对原子变量读出来后并且做修改操作,返回修改之前的值
- compare_exchange_weak(expected&, val, memory_order_for_success, memory_order_for_failure) : 比较原子变量与expected的值,如果他们是相同的,则用val的值替换原子变量的值,更新成功了就返回true, 更新失败就返回false.;否则如果和expected的值不相同,则用当前的原子变量的值赋值给expected变量。
- compare_exchange_strong(expected&, val, memory_order_for_sucess, memory_order_for_failure) : 比较原子变量与expected的值,如果他们是相同的,则用val的值替换原子变量的值,更新成功了就返回true, 更新失败就返回false.;否则如果和expected的值不相同,则用当前的原子变量的值赋值给expected变量。
大家这里估计会问,compare_exchange_weak和compare_exchange_strong的区别:
1. compare_exchange_weak会比compare_exchange_strong的性能好。
2. compare_exchange_weak的返回值不是每次都可靠准确的,有可能里面expected与原子变量相同,并且去做替换操作了,这个函数还是会返回一个false,这个可以理解为此原子操作还没有执行完成,为了不阻塞执行先返回一个false;所以这个操作一般会在一个不断循环的逻辑里执行,直到返回为true,才代表这个语句执行完成了。当然如果循环里面一直返回是false, 原子操作里面存在条件不满足确实应该返回false.
3. compare_exchange_strong会比comare_exchange_weak性能差,但是保证一次性操作是准确和可靠的。
4. compare_exchange_weak 设计这样子方便在一个线程里面循环去观察某个共享变量值,这样在一个线程里面不停地轮询观察,可以比较高性能地查看。
2.2 内存顺序
我们的程序编译器编译时候会对其优化,对于代码的执行顺序会被重新排序。
在多线程编程下,重新排序会给开发带来不可预测的问题,我们不知道编译器怎么排序。
在这样情况下,多线程编程里面有对于内存执行顺序约束或者指定的方法:
- std::memory_order_relaxed : 程序不对内存执行顺序做保障,不关心内存序
- std::memory_order_acquire : 一定是和load操作绑定,不能用于store操作。如果非要让store和std::meimory_order_acquire绑定,要么编译器报错,要么此操作是未定义的。std::memory_order_acquire和load操作绑定执行后,所有load操作后面的读写操作都在load操作之后执行完成。原子读之后单向保证。
- std::memory_order_release : 一定是和store操作绑定,不能用于load操作。如果非要让load和std::meimory_order_release绑定,要么编译器报错,要么此操作是未定义的。std::memory_order_release和store操作绑定执行后,所有store操作前面的读写操作都在store操作之前执行完成。原子写之前单向保证。
- std::memory_order_acq_rel : 对于原子操作里面既有读操作又有写操作,比如前面提到的compare_exchange_weak/compare_exchange_stronge, fetch_add/fetch_sub操作。这些操作与std::memory_order_acq_rel参数绑定,保证了原子操作之前的读写操作不会排序到原子操作之后,同时保证了原子操作之后的读写操作不会排序到原子操作之前。原子读写操作双向保证。
- std::memory_order_seq_cst : 默认值,这个保证内存执行顺序和代码保持一致,对于不同的原子变量也是如此。这个是最严格的保证内存序。
2.3 例子
#include <iostream>
std::atomic<bool> g_ready = false;
int_8 g_result = 0;
void Producer_Proc() {
g_result = 88;
g_ready.store(true, std::memory_order_release);
}
void Consumer_Proc() {
while(!g_ready.load(std::memory_order_acquire)) {
// sleep 10ms and some other operations
}
std::cout << "result: " << g_result << std::endl;
}
int main() {
std::thread producer(Producer_Proc);
std::thread consumer(Consumer_Proc);
producer.join();
consumer.join();
}
大家看看上面的例子,有两个线程,一个是生产者线程,一个是消费者线程。通过原子变量g_ready的load和store来保证g_result的正确输出。
在线程Producer_Proc里面,原子变量g_ready的store操作与std::memory_order_release绑定,保证了store操作之前的读写操作先执行完成,即g_result = 88操作先完成,这样store操作后,g_ready为true后,也保证了g_result确实更新为88了。
在线程Consumer_Proc里面,原子变量g_ready的load操作与std::memory_order_acquire绑定,保证了load操作之后的读写操作在load操作之后执行。当g_ready更改后,load操作可以获取到其值为true,然后跳出循环,然后输出g_result的操作,即输出g_result的操作一定是在load操作之后执行。
#include <atomic>
class Counter {
private:
std::atomic<int> count;
public:
Counter() : count(0) {}
int increment() {
int expected = count.load();
while (!count.compare_exchange_weak(expected, expected + 1)) {
// 循环重试直至成功
}
return expected + 1;
}
int get() const { return count.load(); }
};
这个程序中运用到了load和compare_exchange_weak操作。
三. 总结
多线程编程中对于内存序重新排序有相应的应对方法,还是要根据具体的情况具体解决。如果对于性能要求不高,第一选择还是用锁的机制。如果对于性能有要求,可以考虑文中用到的方法。

2578

被折叠的 条评论
为什么被折叠?



