在多线程编程中, 有两个需要注意的问题, 一个是数据竞争, 另一个是内存执行顺序.
什么是数据竞争(Data Racing)
我们先来看什么是数据竞争(Data Racing), 数据竞争会导致什么问题.
int counter = 0;void increment() { for (int i = 0; i < 100000; ++i) { // ++counter实际上3条指令 // 1. int tmp = counter; // 2. tmp = tmp + 1; // 3. counter = tmp; ++counter; }}int main() { std::thread t1(increment); std::thread t2(increment); t1.join(); t2.join(); std::cout << "Counter = " << counter << "\n"; return 0;}
在这个例子中, 我们有一个全局变量counter, 两个线程同时对它进行增加操作. 理论上, counter的最终值应该是200000. 然而, 由于数据竞争的存在, counter的实际值可能会小于200000.
这是因为, ++counter并不是一个原子操作, CPU会将++counter分成3条指令来执行, 先读取counter的值, 增加它, 然后再将新值写回counter. 示意图如下:
Thread 1 Thread 2--------- ---------int tmp = counter; int tmp = counter;tmp = tmp + 1; tmp = tmp + 1;counter = tmp; counter = tmp;
两个线程可能会读取到相同的counter值, 然后都将它增加1, 然后将新值写回counter. 这样, 两个线程实际上只完成了一次+操作, 这就是数据竞争.
如何解决数据竞争
c++11引入了std::atomic, 将某个变量声明为std::atomic后, 通过std::atomic的相关接口即可实现原子性的读写操作.
现在我们用std::atomic来修改上面的counter例子, 以解决数据竞争的问题.
// 只能用bruce-initialization的方式来初始化std::atomic.// std::atomiccounter{0} is ok, // std::atomiccounter(0) is NOT ok.std::atomic<int> counter{0}; void increment() { for (int i = 0; i < 100000; ++i) { ++counter; }}int main() { std::thread t1(increment); std::thread t2(increment); t1.join(); t2.join(); std::cout << "Counter = " << counter << "\n"; return 0;}
我们将int counter改成了std::atomiccounter, 使用std::atomic的++操作符来实现原子性的自增操作.
std::atomic提供了以下几个常用接口来实现原子性的读写操作, memory_order用于指定内存顺序, 我们稍后再讲.
// 原子性的写入值std::atomic::store(T val, memory_order sync = memory_order_seq_cst);// 原子性的读取值std::atomic::load(memory_order sync = memory_order_seq_cst);// 原子性的增加// counter.fetch_add(1)等价于++counterstd::atomic::fetch_add(T val, memory_order sync = memory_order_seq_cst);// 原子性的减少// counter.fetch_sub(1)等价于--counterstd::atomic::fetch_sub(T val, memory_order sync = memory_order_seq_cst);// 原子性的按位与// counter.fetch_and(1)等价于counter &= 1std::atomic::fetch_and(T val, memory_order sync = memory_order_seq_cst);// 原子性的按位或// counter.fetch_or(1)等价于counter |= 1std::atomic::fetch_or(T val, memory_order sync = memory_order_seq_cst);// 原子性的按位异或// counter.fetch_xor(1)等价于counter ^= 1std::atomic::fetch_xor(T val, memory_order sync = memory_order_seq_cst);
什么是内存顺序(Memory Order)
内存顺序是指在并发编程中, 对内存读写操作的执行顺序. 这个顺序可以被编译器和处理器进行优化, 可能会与代码中的顺序不同, 这被称为指令重排.
我们先举例说明什么是指令重排, 假设有以下代码:
int a = 0, b = 0, c = 0, d = 0;void func() { a = b + 1; c = d + 1;}
在这个例子中, a = b + 1和c = d + 1这两条语句是独立的, 它们没有数据依赖关系. 如果处理器按照代码的顺序执行这两条语句, 那么它必须等待a = b + 1执行完毕后, 才能开始执行c = d + 1.
但是, 如果处理器可以重排这两条语句的执行顺序, 那么它就可以同时执行这两条语句, 从而提高程序的执行效率. 例如, 处理器有两个执行单元, 那么它就可以将a = b + 1分配给第一个执行单元, 将c = d + 1分配给第二个执行单元, 这样就可以同时执行这两条语句.
这就是指令重排的好处:它可以充分利用处理器的执行流水线, 提高程序的执行效率.
但是在多线程的场景下, 指令重排可能会引起一些问题, 我们看个下面的例子:
std::atomic<bool> ready{false};std::atomic<int> data{0};void producer() { data.store(42, 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() { // launch一个生产者线程 std::thread t1(producer); // launch一个消费者线程 std::thread t2(consumer); t1.join(); t2.join(); return 0;}
在这个例子中, 我们有两个线程:一个生产者(producer)和一个消费者(consumer).
生产者先将data的值修改为一个有效值, 比如42, 然后再将ready的值设置为true, 通知消费者可以读取data的值了.
我们预期的效果应该是当消费者看到ready为true时, 此时再去读取data的值, 应该是个有效值了, 对于本例来说即为42.
但实际的情况是, 消费者看到ready为true后, 读取到的data值可能仍然是0. 为什么呢?
一方面可能是指令重排引起的. 在producer线程里, data和store是两个不相干的变量, 所以编译器或者处理器可能会将data.store(42, 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.
这就是我们所说的内存顺序不一致问题.
为了避免这个问题, 我们需要在producer线程中, 在data和ready的更新操作之间插入一个内存屏障(Memory Barrier), 保证data和ready的更新操作不会被重排, 并且保证data的更新操作先于ready的更新操作.
需要C/C++ Linux服务器架构师学习资料加qun579733396获取(资料包括C/C++,Linux,golang技术,Nginx,ZeroMQ,MySQL,Redis,fastdfs,MongoDB,ZK,流媒体,CDN,P2P,K8S,Docker,TCP/IP,协程,DPDK,ffmpeg等),免费分享

现在我们来修改上述的例子, 来解决内存顺序不一致的问题.
std::atomic<bool> ready{false};std::atomic<int> data{0};void producer() { data.store(42, 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(); // 啥也不做, 只是让出CPU时间片 } // 当ready为true时, 再原子性的读取data的值 std::cout << data.load(memory_order_relaxed); // 4. 消费者线程使用数据}int main() { // launch一个生产者线程 std::thread t1(producer); // launch一个消费者线程 std::thread t2(consumer); t1.join(); t2.join(); return 0;}
我们只是做了两处改动,
-
将producer线程里的ready.store(true, std::memory_order_relaxed);改为ready.store(true, std::memory_order_released);, 一方面限制ready之前的所有操作不得重排到ready之后, 以保证先完成data的写操作, 再完成ready的写操作. 另一方面保证先完成data的内存同步, 再完成ready的内存同步, 以保证consumer线程看到ready新值的时候, 一定也能看到data的新值.
-
将consumer线程里的while (!ready.load(memory_order_relaxed))改为while (!ready.load(memory_order_acquire)), 限制ready之后的所有操作不得重排到ready之前, 以保证先完成读ready操作, 再完成data的读操作;
现在我们再来看看memory_order的所有取值与作用:
-
memory_order_relaxed: 只确保操作是原子性的, 不对内存顺序做任何保证, 会带来上述produer-consumer例子中的问题.
-
memory_order_release: 用于写操作, 比如std::atomic::store(T, memory_order_release), 会在写操作之前插入一个StoreStore屏障, 确保屏障之前的所有操作不会重排到屏障之后, 如下图所示
+ | | | No Moving Down |+-----v---------------------+| StoreStore Barrier |+---------------------------+
-
memory_order_acquire: 用于读操作, 比如std::atomic::load(memory_order_acquire), 会在读操作之后插入一个LoadLoad屏障, 确保屏障之后的所有操作不会重排到屏障之前, 如下图所示:
+---------------------------+| LoadLoad Barrier |+-----^---------------------+ | | | No Moving Up | | +
-
memory_order_acq_rel: 等效于memory_order_acquire和memory_order_release的组合, 同时插入一个StoreStore屏障与LoadLoad屏障. 用于读写操作, 比如std::atomic::fetch_add(T, memory_order_acq_rel).
-
memory_order_seq_cst: 最严格的内存顺序, 在memory_order_acq_rel的基础上, 保证所有线程看到的内存操作顺序是一致的. 这个可能不太好理解, 我们看个例子, 什么情况下需要用到memory_order_seq_cst:
#include#include#include std::atomic<bool> x = {false};std::atomic<bool> y = {false};std::atomic<int> z = {0}; void write_x(){ x.store(true, std::memory_order_seq_cst);} void write_y(){ y.store(true, std::memory_order_seq_cst);} void read_x_then_y(){ while (!x.load(std::memory_order_seq_cst)) ; if (y.load(std::memory_order_seq_cst)) { ++z; }} void read_y_then_x(){ while (!y.load(std::memory_order_seq_cst)) ; if (x.load(std::memory_order_seq_cst)) { ++z; }} int main(){ std::thread a(write_x); std::thread b(write_y); std::thread c(read_x_then_y); std::thread d(read_y_then_x); a.join(); b.join(); c.join(); d.join(); assert(z.load() != 0); // will never happen}
以上这个例子中, a, c, c, d四个线程运行完毕后, 我们期望z的值一定是不等于0的, 也就是read_x_then_y和read_y_then_x两个线程至少有一个会执行++z. 要保证这个期望成立, 我们必须对x和y的读写操作都使用memory_order_seq_cst, 以保证所有线程看到的内存操作顺序是一致的.
如何理解呢,我们假设将所有的 memory_order_seq_cst 替换为 memory_order_relaxed,则可能的执行顺序如下:
-
线程 A:write_x()执行时,x 被设为 true。
-
线程 B:write_y()执行时,y 被设为 true。
-
线程 C:read_x_then_y() 里面读取到 x 是 true,但由于 memory_order_relaxed 不保证全局可见性,y.load()可能仍然读取到 false。
-
线程 D:read_y_then_x() 里面读取到 y 是 true,但同样,x.load()可能仍然读取到 false。
如果我们使用的是memory_order_seq_cst,就不会遇到这种情况。线程C和线程D的看到的x和y的值就会是一致的。
这样说可能还是会比较抽象, 我们可以直接理解下memory_order_seq_cst是怎么实现的. 被memory_order_seq_cst标记的写操作, 会立马将新值写回内存, 而不仅仅只是写到Cache里就结束了; 被memory_order_seq_cst标记的读操作, 会立马从内存中读取新值, 而不是直接从Cache里读取. 这样相当于write_x, write_y, read_x_then_y, read_y_then_x四个线程都是在同一个内存中读写x和y, 也就不存在Cache同步的顺序不一致问题了.
理解memory_order_seq_cst的实现后,我们再回过头来就比较好理解为什么使用memory_order_relaxed 时线程C和线程D里看到的x和y的值会不一样了。
我们来看下面这张图,如果我们使用的是memory_order_relaxed ,则:
-
write_x()与read_y_then_x()放到 Core1 执行
-
write_y()与read_x_then_y()放到 Core2 上执行。
结果就会是:
-
write_x只会把自己 Core 里 Cache 的 x 更新为 true,也就只有 Core1 能看到 x为 true,而 Core2 里的 x 还是 false。
-
write_y只会把自己 Core 里 Cache 的 y 更新为 true,也就只有 Core2 能看到 y为 true,而 Core1 里的 y 还是 false。

而如果我们用的是memory_order_seq_cst,那么write_x()和write_y()都会把 Cache 与 Memory 里的值一起更新,read_x_then_y()与read_y_then_x()都会从 Memory 里读值,而不是从自己 Core 里的 Cache 里读,这样它们看到的 x和y的值就是一样的了。
所以我们也就能理解为什么memory_order_seq_cst会带来最大的性能开销了, 相比其他的memory_order来说, 因为相当于禁用了CPU Cache.
memory_order_seq_cst常用于multi producer - multi consumer的场景, 比如上述例子里的write_x和write_y都是producer, 会同时修改x和y, read_x_then_y和read_y_then_x两个线程, 都是consumer, 会同时读取x和y, 并且这两个consumer需要按照相同的内存顺序来同步x和y.