Item 40: Use std::atomic for concurrency, volatile for special memory.

Item 40: Use std::atomic for concurrency, volatile for special memory.

本 Item 探讨一下 atomic 类型和 volatile 关键字在并发程序中的区别和应用。

C++11 提供了 std::atomic 类模版,可以保证操作的原子性,确保其他线程看到的肯定是操作后的结果。类似对操作加锁,而其内部使用特殊指令实现,因而开销较小。考虑下面的应用场景:

std::atomic<int> ai(0);  // initialize ai to 0
ai = 10;                 // atomically set ai to 10
std::cout << ai;         // atomically read ai's value
++ai;                    // atomically increment ai to 11
--ai;                    // atomically decrement ai to 10

在上面代码执行过程中,其他线程读取 ai 的值只能是 01011,不会有其他的值。这里面有两个方面值得注意:

  • 其一,std::cout << ai 这整个操作不是原子的,只能保证 ai 的读取是原子的,不能保证整个语句是原子的,也就是说在 ai 读取后和写到标准输出之间的时刻,ai 的值可以被其他线程修改。不过,也不影响到 ai 的输出值,因为 operator<< 是值拷贝的。
  • 其二,对于最后两条语句(++ai--ai),它们都是 read-modify-write(RMW)类型操作,都是原子执行的。

而对于使用 volatile 关键字的对应的例子:

volatile int vi(0);  // initialize vi to 0
vi = 10;             // set vi to 10
std::cout << vi;     // read vi's value
++vi;                // increment vi to 11
--vi;                // decrement vi to 10

在上述代码执行过程中,其他线程读取到 vi 值可能是任一值,例如 -12684090727,这是一种位定义的行为。再考虑下面的场景:

std::atomic<int> ac(0);  // "atomic counter"
volatile int vc(0);      // "volatile counter"

// Thread 1
++ac;
++vc;

// Thread 2
++ac;
++vc;

两个线程完成后,ac 的值肯定是 2,因为 ac 的 RMW 过程是保证原子的。但 vc 的值却不一定是 2,因为 vc 的 RMW 过程可以是交替进行的,例如:

  1. 线程1 读取 vc 的值,为 0。
  2. 线程2 读取 vc 的值,仍然为 0。
  3. 线程1 将读取的 vc 值从增加到 1,然后写进 vc 的内存。
  4. 线程2 将读取的 vc 值从增加到 1,然后写进 vc 的内存。

这样, vc 的值最终为 1vc 最终的值是不可预测的,这是一种未定义的行为。

这种 RMW 行为的原子性并不是关键字 volatileatomic 类型的唯一区别。考虑这样一个场景:当一个线程完成一个重要计算后,通知另外一个线程。Item 39: Consider void futures for one-shot event communication. 讨论这一场景的方案。这里,我们使用 atomic 变量通信。代码类似如下:

std::atomic<bool> valAvailable(false);
auto imptValue = computeImportantValue(); // compute value
valAvailable = true;                      // tell other task it's available

从代码顺序上看,imptValue 的赋值发生在 valAvailable 赋值之前。但事实并未一定如此,编译器可以对改变二者的执行顺序以提高性能,例如:

a = b;
x = y;

因为两个赋值语句不互相依赖,编译器可以重排序如下:

x = y;
a = b;

即使编译器不重排序,底层的硬件也可能做重排序。

但是 std::atomic 的使用禁止了编译器和底层硬件对这段代码的重排序,这种行为称为顺序一致性模型。而 volatile 无法阻止这种重排序。

volatile 无法保证操作的原子性和无法阻止指令的重排序,这就导致了它在并发编程中很少使用,那么 volatile 的使用场景是什么呢?简而言之,volatile 用于告诉编译器它所处理的内存表现的不太“正常”。“正常”的内存有这样的特点:将一个值写入内存,这个值保持不变,直到它被改写。例如:

auto y = x; // read x
y = x;      // read x again

上面的代码中,多次读取 x 的值,编译器可以这样优化:会将 x 的值放在寄存器中,再读取 x 的值时,直接从寄存器中读取即可。

对于写内存,编译器也会做优化。例如:

x = 10; // write x
x = 20; // write x again

编译器会进行优化:只执行了 x = 12 条语句,而删除 x = 10 这条语句。

上述的优化对于“正常”行为的内存是适用的,但对于特殊的内存并不适用。最常见的这种特殊内存用于 memory-mapped I/O,这种内存用于和外设通信:

auto y = x; // read x
y = x;      // read x again

这样的两次写内存都会对外设产生影响。例如外设根据该内存的值显示波形,那么上述多条写内存的操作就不是冗余的。对于这种情况来说必需使用 volatile 来告诉编译器禁止对变量的读写进行优化。例如:

volatile int x;

auto y = x; // read x
y = x;      // read x again (can't be optimized away)

x = 10;  // write x (can't be optimized away)
x = 20;  // write x again

std::atomic 无法做到这一点。例如:

std::atomic<int> x; 
x = 10;  // write x
x = 20;  // write x again

可能被编译器优化为:

std::atomic<int> x; 
x = 20;  // write x

而对于:

std::atomic<int> x; 
auto y = x; // error!
y = x;      // error!

实际上无法编译的,因为 std::atomic 的拷贝操作是被 deleted 的。std::atomic 的成员函数 loadstore 可以提供这样的功能:

td::atomic<int> y(x.load()); // read x
y.store(x.load());           // read x again

对于上述代码,编译器可能优化为:

register = x.load();           // read x into register
std::atomic<int> y(register);  // init y with register value
y.store(register);             // store register value into y

可以将二者结合起来使用。例如:

volatile std::atomic<int> vai;  // operations on vai are
                                // atomic and can't be
                                // optimized away

可以用于 memory-mapped I/O 内存,并被多线程访问。

参考:

  • 4
    点赞
  • 5
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值