《C++ Concurrency In Action》读书笔记 - C++内存模型
本书第五章讲的是C++内存模型和原子操作。如果没有了解过内存模型,可能会在读这一章的时候云里雾里,不知道内存模型到底是干什么的。可以先通过这篇文章科普一下什么是内存模型)。简单的理解C++内存模型就是,C++规定的跟程序员之间的一个协议,协议内容主要是保证在data race的情况下程序的行为是什么样的。
C++内存模型
在谈具体的C++内存模型之前,需要先了解一下什么是重排(reordering),以及重排会给我们带来什么样的问题。
重排主要存在于两个阶段,一个阶段是编译时,当C++代码编译成指令的时候编译器会在不影响程序的前提下对程序进行重排和优化。另一个是运行时,处理器会在对结果没有影响的情况下对指令进行重排。两个重排的目的都是为了提升性能。这两个重拍的前提都是它们认为对结果不会产生影响,这个推导在单线程的情况下是成立的。但是当这段代码涉及到多线程,情况可能就会不如你所愿。举个很简单的例子, y==42
乍看之下不可能为false,但是这个是不能够100%保证的。为什么?因为重排。而重排按照以下这种写法是无法控制的。
1 | int x = 0; |
这也就是内存模型所要解决最大的问题:对抗重排。更加具体一点,C++内存模型主要要解决的问题是:
- 对抗重排,让程序能够按照我们想要的顺序去执行,能够在任何情况下都保证结果正确,而是不靠运气;
- 上面代码除了重排以外还存在的问题就是,在访问同一块内存的时候没有加保护,而这个是会导致未定义的行为的,内存模型需要解决data race的情况下给出一套可预期的行为。
通俗版本
那么对抗重排不可能说让整个程序都不允许任何重排,这个是不可能的。准确的讲,我们其实是不想看到会影响我们预期效果的重排。拿上面那个程序举个例子,上面的M1和M2之间其实存在着某种依赖关系。当M2.1中x!=1
不成立的时候,我们期望看到的是M1.1已经完成执行了,我们可以理解成M2.2依赖于M1.1的操作。
那么对抗重排的武器是什么,是fence,又叫memory barrier。我们可以在M1.2处设置一个边界,清楚地告诉编译器和处理器你们在这个边界上面乱搞可以,但是请不要跑到下面来。然后在M2.1处也设置一个边界,告诉编译器和处理器你们在这行下面乱搞可以,但是请不要跑到上面来。
既然是边界,那么就有两个方向,根据不同的规则可以定义以下四种边界:CAN_UP_CAN_DOWN(能上能下),CAN_UP_NO_DOWN(能上不能下),NO_UP_NO_DOWN(不能上不能下),NO_UP_CAN_DOWN(不能上能下)。
那么我们改写一下上面的代码
1 | // run at thread 1 |
那么对应到C++中的内存模型的话可以用下面这幅图来理解
用这种通俗的理解方法,这四种内存模型(memory_order_relaxed, memory_order_acquire, memory_order_release, memory_order_seq_cst)应该就不难理解了。还有一种比较特殊的是memory_order_consume,这个是上面第四个的一个弱化版。还是拿我们这个程序来举例,我们这个例子中M2.2其实只依赖于M1.1这一行,而不依赖于其他行的代码。所以我们只需要保证M1.1在CAN_UP_NO_DOWN以上,M2.2在NO_UP_CAN_DOWN以下就能够保证正确性了,没有必要一竿子把所有的操作都限制住。可以让更多的表达式通过fence,自由自在的优化。
专业版本
上面用我自己理解的通俗版本介绍了这几种内存模型的区别,专业版的话需要搞清楚两个概念:Happens-Before和 Synchronizes-With。推荐大家直接阅读参考资料中的两篇文章,讲得很好,以我目前的理解水平还不能够比这两篇文章更好更通俗的去解释这两个概念。读完就基本知道是怎么回事了。
C++ atomic注意事项
使用C++中的atomic有以下一些注意事项:
- 预定义的
atomic
类型基本都重载了常用的操作符,所以你如果用起来的话可以跟普通基础类型一样。但是默认用的memory order是最强的memory_order_seq_cst
。所以你在理解memory oder的情况下应该尽可能的使用相对较弱的memory order。特别是如果你的代码可能跑在ARM等弱内存序的处理器上。 - 如果要使用一个自定义的类型扩展
std::atomic
,那么这个类型必须满足一下两个条件:这个类型及其所有父类和非静态的成员都必须有trival copy-assignment操作符,简单地说就是能够用memcpy
直接进行拷贝;二,必须是在字节层面能够直接比较的,可以理解为能够用memcmp
进行比较