在多线程编程中, 有两个需要注意的问题, 一个是数据竞争, 另一个是内存执行顺序.
什么是数据竞争(Data Racing)
我们先来看什么是数据竞争(Data Racing), 数据竞争会导致什么问题.
#include <iostream>
#include <thread>
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<T>
, 将某个变量声明为std::atomic<T>
后, 通过std::atomic<T>
的相关接口即可实现原子性的读写操作.
现在我们用std::atomic<T>
来修改上面的counter
例子, 以解决数据竞争的问题.
#include <iostream>
#include <thread>
#include <atomic>
// 只能用bruce-initialization的方式来初始化std::atomic<T>.
// std::atomic<int> counter{0} is ok,
// std::atomic<int> counter(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::atomic<int> counter
, 使用std::atomic<int>
的++
操作符来实现原子性的自增操作.
std::atomic
提供了以下几个常用接口来实现原子性的读写操作, memory_order
用于指定内存顺序, 我们稍后再讲.
// 原子性的写入值
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)
内存顺序是指在并发编程中, 对内存读写操作的执行顺序. 这个顺序可以被编译器和处理器进行优化, 可能会与代码中的顺序不同, 这被称为指令重排.
我们先举例说明什么是指令重排, 假设有以下代码:
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分配给第二个执行单元, 这样就可以同时执行这两条语句.
这就是指令重排的好处:它可以充分利用处理器的执行流水线, 提高程序的执行效率.
但是在多线程的场景下, 指令重排可能会引起一些问题, 我们看个下面的例子:
#include <iostream>
#include <thread>
#include <atomic>
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
的更新操作.
现在我们来修改上述的例子, 来解决内存顺序
不一致的问题.
#include <iostream>
#include <thread>
#include <atomic>
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 <thread>
#include <atomic>
#include <cassert>
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
.