effective c++ 条款40 并发场景使用std::atomic ,特殊内存场景使用volatile

本文讨论了C++中std::atomic和volatile在并发编程中的作用。std::atomic提供原子操作,确保多线程环境中的数据一致性,而volatile并不保证原子性,主要用于内存映射I/O等特殊内存操作,防止编译器优化。在并发场景下,std::atomic是更好的选择。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

可怜的volatile,被误解如此之深,就不应该出现在本章,因为它和并发编程毫无关系。但在其他语言(java或者c#),它在并发编程中很用。在某些c++编辑器中,volatile被赋予了特殊语义,使其可以用在并发编程中(但也只有用这些编译器编译才可以用)。即使为了澄清围绕它产生的误解,在关于并发编程的本章中探讨一下volatiile也是有价值的。

c++容易混淆属于volatile和std::atoimic模板的特征。模板的实例化(如std::atoimic<int> std::atomic<bool>和std::atomic<Widget*>)提供了不同线程之间的原子操作。一旦std::atomic对象构建出来,对其访问,就如同在mutex保护的关键代码中访问一样。然而这些操作由特殊的机器指令来实现,比使用mutex保护的效率要高很多。

考虑下面使用std::atomic的代码:

std::atomic<int> ai(0);        //初始化ai为0

ai = 10;                             //原子设置ai为10

std::cout << ai;                  //原子打印ai

ai++;                               // 原子增加ai到11

--ai ;                              //原子减小ai到10

在这些语句操作过程中,别的线程读取ai的值只可能为(0,10,11,10)。别的值都不可能出现(当然我们假定了只有一个线程更改)

上面例子中两个语句值得一提。一个是std::cout << ai。因为事实上std::atomic只保证读取ai是原子操作,并不保证整条语句都是原子的。在读取ai和<<操作输出到标准输出之间,别的线程可能会更改ai的值。这对输出无影响,因为int 的<<操作是值传递(所以智慧输出读取到的值)。但重要的是要理解,这条语句中的原子操作不过是单指读取ai。

另外一个值得一提的是最后两个语句的行为——自增和自减。它们每个都是读改写(RMW)操作,因此它们进行原子操作。这是std::atomic类型最优雅之处,一旦一个std::atomic对象创建出来,所有成员函数操作,包括自增和自减之类的(RMW)都是原子操作。

相反,多线程上下文使用volatile什么都不会保证

volatile int vi(0);//初始化为0

vi=10;                   //设置vi为10

std::cout << vi;         // 读取vi值

++vi;                       //自增

--vi;                        //自减

如果此时别的线程在读取vi,它们可以读取到任何值(比如-12,68,4090727)。这些代码会引起未定义行为,因为这些语句在更改vi时,别的线程可能正在读取ai。同时读写一块内存,而这块内存既没有定义成std:;atomic的,又没有被mutex保护,这就是数据竞争的定义。

为了比较std::atomic和volatile在多线程下行为差异,考虑多线程下的这两种类型计数器。我们都初始化为0:

std::atomic<int> ac(0);            //原子类型计数器

volatile int vc(0);                      // volatile类型计数器

然后我们将在两个线程中,对其自增:

thread1:                   thread2:

++ac;                           ++ac;

++vc;                            ++vc;

操作完成后,ac结果为2,而vc的结果不一定为2. 因为vc的增加不是原子操作,它包含了读取,更改和写入操作。

可能有下面一种时序:

1.线程1读取vc为0

2.线程2读取vc为0

3.线程1增加vc为1,写入1

4线程2增加vc为1, 写入1

vc最终结果可能为1,尽管它看起来被增加了2次。

这并不是唯一的结果。因为vc涉及数据竞争,而标准说数据竞争会出现未定义行为,所以编译器按字面意思可以生成出代码做出任何行为。编译器当然不会恶意使用这个自由。它们只是会对代码进行优化,这种优化在没有数据竞争情况下是合法的,但在数据竞争情况下则会引起未定义行为。

std::atoimic会成功,volatile会失败,RMW并不是唯一的操作事例。设想第一个线程计算得到一个重要的数值,第二个线程需要得到这个数值。根据条款39,可以使用std::atomic<bool> 来传递。

std::atomic<bool> valAviable{false}; //初始化false

auto importantValue = computeValue();        //计算

importantValue = true;

在人读到这些代码时,我们知道确保importantValue 在importantValue 赋值之前赋值很关键。但在计算机看来,这是两个独立的赋值语句。通常会允许编译器调整两个无关语句顺序。这意味着,下面的顺序(用a,b,x,y表示无关变量)

a=b;

x=y;

编译器可以调整为:

x=y;

a=b;

即使编译器不进行重排,底层的硬件也可能会去重排,因为这可以加快代码运行速度。

而使用std::atomic后,编译器会按照代码重排顺序插入额外指令,确保了源代码中std::atomic的写入不会被放在后面进行。

这意味着我们代码中:

auto importantValue = computeValue();        //计算

importantValue = true;

不仅编译器要保持这两个变量的赋值顺序,而且底层硬件也要保持。std::atomic最终确保了importantValue的改变 对线程的可见性,不晚于importantValue 。

声明volatile,并不会带来代码重排限制。

volatile importantValue (false);

auto importantValue = computeValue();        //计算

importantValue = true;        //别的线程可能会先看到这条赋值语句,而后看到importantValue语句。即使编译器不这么做,硬件和别的核心也可能会先看到importantValue 的改变。

不保证操作原子性,以及不充分的代码重排限制,这两点解释了volatile不适合并发编程领域原因,但也没解释它适合什么领域。概括的说,它告诉编译器,正在操作的内存其行为不同寻常。

正常的内存一旦写入数据,将会一直保持,直到有人去在同一个位置写入另外的内容。我有一个正常内存的int,

int x;

编译器看到下面代码:

auto y = x    // 读取x    

y=x;             //再次读取x

编译器会优化上面代码,省略y=x,因为和前面初始化形成了冗余。

正常内存还有另外一个特征,如果你写入变量一个值,不去读取它,又写入一个值,那么编译器会忽略第一个写入语句,因为从来没有使用。有下面相邻语句:

x=10;                        // write x

x=20;                        // write x again

如果有下列语句:

auto y =x;     //read x

y=x;               // read x again

x=10;             // write x

x=20;             // write x again

编译器优化以后,看起来会是这样:

auto y=x;         //read x

x=20;               //write x

你会想知道谁会写出这样冗余的读写操作代码,答案是人类不会直接写出——至少我们不希望写出。然而编译器检查完源代码,进行模板实例化,以及进行许多常规优化之后,就可能会出现冗余的读写操作。

只有在内存是正常内存前提下,优化才是合法的。如果是特殊的内存,则是非法的。可能最常见的特殊内存是内存映射I/O. 访问这些内存,实际上是与外设(外部寄存器,显示器,打印机和网络端口),而不是读写RAM。在这样上下文中,重新考虑这似乎冗余的代码:

auto y =x; //读取x

y = x; //再次读取x

如果x与温度寄存器关联,那么第二次读取就不是冗余读取,因为在两次读取之间,寄存器的数值可能会变化。

显得冗余的写,也是同样情况,如下面代码:

x=10;//第一次写

x=20;//第二次写

如果x与一个无线发射器的端口关联,那么代码可能是在给发射器发送命令。10和20都代表不同的命令,将第一个命令优化掉,就改变了命令序列。

通过volatile我们就告知编译器在处理一种特殊的内存。编译器理解的是:不要对代码的内存操作做任何优化。如果x代表特殊内存,可以这样来定义:

volatile int x

考虑到对我们代码影响:

auto y=x; //读取x

y=x;//再次读取x 不能优化

x=10  // 写入10 不能优化

x=20;//再次写入

这就是当x被内存映射时,我们确切想要的。

小测验,猜一猜y的类型是什么,volatile int还是int?

对特殊内存进行的似乎冗余的操作,解释了std::atomic不适合这类工作。编译器允许对std::atomic类型的冗余操作优化。

std::atomic<int> x;

auto y = x; //概念上读取

y=x;  // 概念上再次读

x=10;//写入

x=20;//再次写入

优化后:

auto y = x  // 概念上读取x

x=20;//写入x

对于特殊内存来说,很明显这是不可接受的。

现在,当x是atomic类型时,两条语句都不能编译通过:

auto y=x  / /error

y=x         //error

这是因为std::atomic类型的拷贝构造函数是delete类型(条款11),这也有很好的理由。如果y=x能通过编译,想想会发生什么。因为x是atomic类型,那么y的类型可以推定同为atomic。我说过atomic的最好之处在于所有操作都是原子的。为了使拷贝也是原子的,编译器必须生成一条原子的指令完成读取x和写入y。硬件无法实现这个操作,所以原子类型不支持拷贝操作。同样的原因,拷贝赋值也不能编译通过。(还有move拷贝和move赋值)

从x读取数值到y是可以的,但需要用std::atomic的成员函数load 和store。load原子读取,而store原子写入。将x值赋值给y,必须这样写:

std::atomic<int> y(x.load()) //readx

y.store(x.load());// readx again

读取x和初始化y,写入y是分离的调用,所以它们整体是原子操作。

编译器使用寄存器来优化读取,避免读取两次:

register = x.load();

std::atomic<int> y(register)

y=register;

结果是只对x读取1次,对于特殊内存来说,应该要避免的。(对于volatile类型,这种优化是禁止的)

因为atomic和volatile各自有各自用途,所以二者可以一起使用:

volatile std::atomic<int> val; //val的操作都是原子的,并且不能优化掉

这在并发访问内存映射I/O时候有用。

最后说一点,有些开发者喜欢使用std::atomic 的load和store成员函数,即使这种使用是非必须的。原因是可以在源代码表明这写变量是一种非常规的。强调这一事实并不无道理。访问一个atomic类型的变量通常要比非stomic类型的变量慢很多。而且我们知道使用atomic类型变量,会阻止一些种类的代码重排序,而这些操作在不使用atomic情况下是允许的。使用load和store有助于识别影响规模的瓶颈,从正确的角度来看,不在同其他线程的通信(比如一个标志数据准备好的变量)变量上使用store,意味着变量没有被定义成std::atomic,而本来应该定义成的。

这很大程度上是个风格问题,和在std::atomic和volatile之间做出选择有着极大的不同。

注意事项

std::atomic用于不同线程之间在不用mutex下数据的并发访问。是并发编程的一个工具

volatile用于对内存的读写操作不应优化掉的情况。是与操纵某些特殊内存的工具。

 

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值