目录
一、内存顺序
二、 指令重排在多线程中的问题
2.1 问题与原因
2.2 解决方案
三、六种内存序
3.1 memory_order_relaxed
3.2 memory_order_consume
3.3 memory_order_acquire
3.4 memory_order_release
3.5 memory_order_acq_rel
3.6 memory_order_seq_cst
一、内存顺序
内存顺序是指在并发编程中, 对内存读写操作的执行顺序. 这个顺序可以被编译器和处理器进行优化, 可能会与代码中的顺序不同, 这被称为指令重排。
如下代码,如果不处理器不能重拍两个加法的指令,则只能一行一行去执行;但是如果可以重拍指令,则可以在不同的处理单元中并行执行这两个加法操作,发挥处理器执行流水线的优势。
int x = 0, y = 1, a = 0, b = 1;
void testMemoryOrder() {
a = b + 2;
x = y + 2; }
二、 指令重排在多线程中的问题
2.1 问题与原因
在多线程中指令重排会引起一些问题,比如如下场景:
std::atomic<bool> ready{false};
std::atomic<int> data{0};void producer() { data.store(100, std::memory_order_relaxed); // 原子性的更新data的值, 但是不保证内存顺序 ready.store(true, std::memory_order_relaxed); // 原子性的更新ready的值, 但是不保证内存顺序
}void consumer() { // 原子性的读取ready的值, 但是不保证内存顺序 while (!ready.load(memory_order_relaxed)) { std::this_thread::yield(); //让出CPU时间片 } // 当ready为true时, 再原子性的读取data的值 std::cout << data.load(memory_order_relaxed); // 4. 消费者线程使用数据
}int main() { std::thread t1(producer); std::thread t2(consumer); t1.join(); t2.join(); return 0;
}
我们预期的效果应该是当消费者看到ready为true时, 此时再去读取data的值。但实际的情况是, 消费者看到ready为true后, 读取到的data值可能仍然是0。
一方面可能是指令重排引起的:在producer线程里, data和store是两个不相干的变量, 所以编译器或者处理器可能会将data.store(100, std::memory_order_relaxed);重排到ready.store(true, std::memory_order_relaxed);之后执行, 这样consumer线程就会先读取到ready为true, 但是data仍然是0。
另一方面可能是内存顺序不一致引起的: 即使producer线程中的指令没有被重排, 但CPU的多级缓存会导致consumer线程看到的data值仍然是0。下面这张示意图来说明这个问题和CPU多级缓存的关系。
每个CPU核心都有自己的L1 Cache与L2 Cache。producer线程修改了data和ready的值, 但修改的是L1 Cache中的值,producer线程和consumer线程的L1 Cache并不是共享的,所以consumer线程不一定能及时的看到producer线程修改的值。CPU Cache的同步是件很复杂的事情, 生产者更新了data和ready后,还需要根据MESI协议将值写回内存,并且同步更新其他CPU核心Cache里data和ready的值,这样才能确保每个CPU核心看到的data和ready的值是一致的。而data和ready同步到其他CPU Cache的顺序也是不固定的,可能先同步ready,再同步data, 这样的话consumer线程就会先看到ready为true, 但data还没来得及同步,所以看到的仍然是0。
2.2 解决方案
void producer()
{ data.store(100, std::memory_order_relaxed); // 原子性的更新data的值, 但是不保证内存顺序 ready.store(true, std::memory_order_released); // 保证data的更新操作先于ready的更新操作
}void consumer()
{ // 保证先读取ready的值, 再读取data的值 while (!ready.load(memory_order_acquire)) { std::this_thread::yield(); } // 当ready为true时, 再原子性的读取data的值 std::cout << data.load(memory_order_relaxed);}
-
ready.store(true, std::memory_order_released):一方面限制ready之前的所有操作不得重排到ready之后,以保证先完成data的写操作, 再完成ready的写操作。 另一方面保证先完成data的内存同步, 再完成ready的内存同步,以保证consumer线程看到ready新值的时候,一定也能看到data的新值。
-
ready.load(memory_order_acquire): 限制ready之后的所有操作不得重排到ready之前, 以保证先完成读ready操作,再完成data的读操作;
三、六种内存序
多线程程序中,为了保证程序的一致性和正确性,需要对内存操作的顺序进行控制。因为在现代处理器中,由于缓存一致性、乱序执行等优化,指令可能不会按顺序执行。内存序允许开发者显式地指定不同操作的顺序,以保证数据的一致性。
C++11 引入了 <atomic> 头文件,并定义了几种内存序类型,来控制原子操作的执行顺序,std::atomic提供了以下几个常用接口来实现原子性的读写操作:
// 原子性的写入值
std::atomic<T>::store(T val, memory_order sync = memory_order_seq_cst);
// 原子性的读取值
std::atomic<T>::load(memory_order sync = memory_order_seq_cst);
// 原子性的增加 counter.fetch_add(1)等价于++counter
std::atomic<T>::fetch_add(T val, memory_order sync = memory_order_seq_cst);
// 原子性的减少 counter.fetch_sub(1)等价于--counter
std::atomic<T>::fetch_sub(T val, memory_order sync = memory_order_seq_cst);
// 原子性的按位与 counter.fetch_and(1)等价于counter &= 1
std::atomic<T>::fetch_and(T val, memory_order sync = memory_order_seq_cst);
// 原子性的按位或 counter.fetch_or(1)等价于counter |= 1
std::atomic<T>::fetch_or(T val, memory_order sync = memory_order_seq_cst);
// 原子性的按位异或 counter.fetch_xor(1)等价于counter ^= 1
std::atomic<T>::fetch_xor(T val, memory_order sync = memory_order_seq_cst);
memory_order用于指定内存顺序不同的内存序提供了不同的同步和顺序保证,从最弱到最严格依次如下 :
memory_order_relaxed(松散顺序)
memory_order_consume(消费顺序)
memory_order_acquire(获取顺序)
memory_order_release(释放顺序)
memory_order_acq_rel(获取-释放顺序)
memory_order_seq_cst(顺序一致性)
3.1 memory_order_relaxed
基本概念:最宽松的内存序,它只保证操作的原子性,不涉及任何线程间的同步或顺序保证。这种方式下,编译器和CPU可以任意重排指令,但仍然保证操作是原子的。
应用场景: 当只需要在多线程环境中执行简单的原子操作,而不需要与其他线程同步时,memory_order_relaxed 是最佳选择,典型场景是简单的计数器或统计类操作。
std::atomic<int> counter(0);
//能保证原子性统计最终一致
void increment() {for (int i = 0; i < 100; ++i) {counter.fetch_add(1, std::memory_order_relaxed); // 放松顺序}
}
3.2 memory_order_consume
基本概念:消费顺序,用于确保在同一线程中,依赖于原子操作结果的读操作不会被重排到该原子操作之前。虽然设计上适用于生产者-消费者模型,但由于硬件优化,memory_order_consume 通常等同于 memory_order_acquire。
应用场景:在多线程中,当一个线程生产数据,另一个线程消费数据并依赖这些数据时,可以使用 memory_order_consume。
std::atomic<int> p;void producer() {p.store(100, std::memory_order_release); // 发布数据
}void consumer() {int p = ptr.load(std::memory_order_consume); std::cout << "Consumed: " << p << std::endl;}
3.3 memory_order_acquire
基本概念:获取顺序,确保在当前线程中,会在读操作之后插入一个LoadLoad屏障, 确保屏障之后的所有操作不会重排到屏障之前。这意味着,在线程读取数据后,它可以看到其他线程对共享变量的修改。
使用场景:当你需要在读取数据时,确保前面的操作已经完成。
std::atomic<int> flag(0);
int data = 0;void writer() {data = 100;flag.store(1, std::memory_order_release); // 先改data更新flag
}void reader() {while (flag.load(std::memory_order_acquire) != 1); // 等待flag更新std::cout << "Data: " << data << std::endl; // 保证读取到最新的data值
}
3.4 memory_order_release
基本概念:释放顺序,确保在当前线程中,会在写操作之前插入一个StoreStore屏障, 确保屏障之前的所有操作不会重排到屏障之后。这意味着,其他线程在看到这个原子操作之后,也可以看到该线程之前的所有修改。
使用场景:当你需要在更新共享数据时,确保在更新操作之前的写入已经完成。同上 flag.store(1, std::memory_order_release);保证执行到此处时,前面的data=100已经执行且对其他线程可见。
3.5 memory_order_acq_rel
基本概念:获取-释放顺序,等效于memory_order_acquire和memory_order_release的组合,同时插入一个StoreStore屏障与LoadLoad屏障,确保当前线程中的操作既能看到其他线程的修改,又可以发布自己的修改。这种顺序适用于同时需要同步读写的场景。
应用场景:当一个线程既要读取其他线程的状态,又要写入新的状态时,使用 memory_order_acq_rel
。例如锁的实现。它的作用是确保 线程 B 在读取 x
时,所有之前对 x
的修改(或者其他操作)都已经完成,并且不会被延迟到 x
被读取之后,线程 B 之后的操作(比如打印 x
的值)不会在 x
读取之前执行。
std::atomic<int> x{0};void threadA() {x.fetch_add(1, std::memory_order_acq_rel); // 获取-释放操作
}void threadB() {// 获取操作while (x.load(std::memory_order_acquire) == 0) { //等待交出时间片}std::cout << "Thread B can proceed";
}
3.6 memory_order_seq_cst
基本概念:顺序一致性(Sequentially Consistent),保证所有线程看到的操作顺序是一致的,原子变量默认顺序。
被memory_order_seq_cst标记的写操作,会立马将新值写回内存,而不仅仅只是写到Cache里就结束了;被memory_order_seq_cst标记的读操作,会立马从内存中读取新值,而不是直接从Cache里读取。这样相当于多个线程读写都在一个内存中,也就不存在Cache同步的顺序不一致问题。相比其他的memory_order,memory_order_seq_cst当于禁用了CPU Cache,会带来最大的性能开销了。
使用场景:当需要确保所有线程对全局状态的顺序一致时,使用 memory_order_seq_cst
。它适合那些需要绝对严格的同步场景。