c 11中的原子量和内存序有什么用-mile米乐体育
c 11中的原子量和内存序有什么用
这篇文章主要讲解了“c 11中的原子量和内存序有什么用”,文中的讲解内容简单清晰,易于学习与理解,下面请大家跟着小编的思路慢慢深入,一起来研究和学习“c 11中的原子量和内存序有什么用”吧!
一、多线程下共享变量的问题
(a) i 问题
在多线程编程中,最常拿来举例的问题便是著名的i 问题,即:多个线程对同一个共享变量i执行i 操作。这样做之所以会出现问题的原因在于i 这个操作可以分为三个步骤:
step | operation |
---|---|
1 | i->reg(读取i的值到寄存器) |
2 | inc-reg(在寄存器中自增i的值) |
3 | reg->i (写回内存中的i) |
上面三个步骤中间是可以间隔的,并非原子操作,也就是说多个线程同时执行的时候可能出步骤的交叉执行,例如下面的情况:
step | thread a | thread b |
---|---|---|
1 | i->reg | |
2 | inc-reg | |
3 | i->reg | |
4 | inc-reg | |
5 | reg->i | |
6 | reg->i |
假设i一开始为0,则执行完第4步后,在两个线程都认为寄存器中的值为1,然后在第5、6两步分别写回去。最终两个线程执行完成后i的值为1。但是实际上我们在两个线程中执行了i ,原本希望i的值为2。i 实际上可以代表多线程编程中由于操作不是原子的而引发的交叉执行这一类的问题,但是在这里我们先只关注对单个变量的操作。
(b)指令重排问题
有时候,我们会用一个变量作为标志位,当这个变量等于某个特定值的时候就进行某些操作。但是这样依然可能会有一些意想不到的坑,例如两个线程以如下顺序执行:
step | thread a | thread b |
---|---|---|
1 | a = 1 | |
2 | flag= true | |
3 | if flag== true | |
4 | assert(a == 1) |
当b判断flag为true后,断言a为1,看起来的确是这样。那么一定是这样吗?可能不是,因为编译器和cpu都可能将指令进行重排(编译器不同等级的优化和cpu的乱序执行)。实际上的执行顺序可能变成这样:
step | thread a | thread b |
---|---|---|
1 | flag = true | |
2 | if flag== true | |
3 | assert(a == 1) | |
4 | a = 1 |
这种重排有可能会导致一个线程内相互之间不存在依赖关系的指令交换执行顺序,以获得更高的执行效率。比如上面:flag 与 a 在a线程看起来是没有任何依赖关系,似乎执行顺序无关紧要。但问题在于b使用了flag作为是否读取a的依据,a的指令重排可能会导致step3的时候断言失败。
mile米乐体育的解决方案
一个比较稳妥的办法就是对于共享变量的访问进行加锁,加锁可以保证对临界区的互斥访问,例如第一种场景如果加锁后再执行i 然后解锁,则同一时刻只会有一个线程在执行i 操作。另外,加锁的内存语义能保证一个线程在释放锁前的写入操作一定能被之后加锁的线程所见(即有happens before 语义),可以避免第二种场景中读取到错误的值。
那么如果觉得加锁操作过重太麻烦而不想加锁呢?c 11提供了一些原子变量与原子操作来支持。
二、 c 11的原子量
c 11标准在标准库atomic头文件提供了模版atomic<>来定义原子量:
template
它提供了一系列的成员函数用于实现对变量的原子操作,例如读操作load,写操作store,以及cas操作compare_exchange_weak/compare_exchange_strong等。而对于大部分内建类型,c 11提供了一些特化:
std::atomic_boolstd::atomic
实际上这些特化就是相当于取了一个别名,本质上是同样的定义。而对于整形的特化而言,会有一些特殊的成员函数,例如原子加fetch_add、原子减fetch_sub、原子与fetch_and、原子或fetch_or等。常见操作符 、--、 =、&= 等也有对应的重载版本。
接下来以int类型为例,解决我们的前面提到的i 场景中的问题。先定义一个int类型的原子量:
std::atomic
由于int型的原子量重载了 操作符,所以i 是一个不可分割的原子操作,我们用多个线程执行i 操作来进行验证,测试代码如下:
#include
在测试中,我们定义了一个原子量i,在main函数开始的时候初始化为0,然后启动10个线程,每个线程执行i 操作十万次,最终检查i的值是否正确。执行的最后结果如下:
start10workers,everywokerinc100000timesworkersendfinallyiis1000000i testpassed!
上面我们可以看到,10个线程同时进行大量的自增操作,i的值依然正常。假如我们把i修改为一个普通的int变量,再次执行程序可以得到结果如下:
start10workers,everywokerinc100000timesworkersendfinallyiis445227i testfailed!
显然,由于自增操作各个步骤的交叉执行,导致最后我们得到一个错误的结果。
原子量可以解决i 问题,那么可以解决指令重排的问题吗?也是可以的,和原子量选择的内存序有关,我们把这个问题放到下一节专门研究。
上面已经看到atomic是一个模版,那么也就意味着我们可以把自定义类型变成原子变量。但是是否任意类型都可以定义为原子类型呢?当然不是,cppreference中的描述是必须为triviallycopyable类型。这个连接为triviallycopyable的详细定义:
http://en.cppreference.com/w/cpp/concept/triviallycopyable
一个比较简单的判断标准就是这个类型可以用std::memcpy按位复制,例如下面的类:
class{intx;inty;}
这个类是一个triviallycopyable类型,然而如果给它加上一个虚函数:
class{intx;inty;virtualintadd(){returnx y;}}
这个类便不能按位拷贝了,不满足条件,不能进行原子化。
如果一个类型能够满足atomic模版的要求,可以原子化,它就不用进行加锁操作了,因而速度更快吗?依然不是,atomic有一个成员函数is_lock_free,这个成员函数可以告诉我们到底这个类型的原子量是使用了原子cpu指令实现了无锁化,还是依然使用的加锁的方式来实现原子操作。不过不管是否用锁来实现,atomic的使用方式和表现出的语义都是没有区别的。具体用哪种方式实现c 标准并没有做约束(除了std::atomic_flag特化要求必须为lock free),跟平台有关。
例如在我的cygwin64、gcc7.3环境下执行如下代码:
#include
n | sizeof(a) | is_lock_free() |
---|---|---|
1 | 1 | 1 |
2 | 2 | 1 |
3 | 3 | 0 |
4 | 4 | 1 |
5 | 5 | 0 |
6 | 6 | 0 |
7 | 7 | 0 |
8 | 8 | 1 |
> 8 | / | 0 |
type | sizeof() | is_lock_free() |
---|---|---|
char | 1 | 1 |
short | 2 | 1 |
int | 4 | 1 |
long long | 8 | 1 |
float | 4 | 1 |
double | 8 | 1 |
step | thread a | thread b |
---|---|---|
1 | a = 1 | |
2 | flag.store(true, memory_order_release) | |
3 | if( true == flag.load(memory_order_acquire)) | |
4 | assert(a == 1) |
step | thread a | thread b |
---|---|---|
1 | b = true | |
2 | a = 1 | |
3 | flag.store(b, memory_order_release) | |
4 | while (!(c = flag.load(memory_order_consume))) | |
5 | assert(a == 1) | |
6 | assert(c == true) | |
7 | assert(b == true) |
step | thread a | thread b |
---|---|---|
1 | a = 1 | |
2 | flag.store(true, memory_order_release) | |
3 | b = true | |
4 | c = 2 | |
5 | while (!flag.compare_exchange_weak(b, false, memory_order_acq_rel)) {b = true} | |
6 | assert(a == 1) | |
7 | if (true == flag.load(memory_order_acquire) | |
8 | assert(c == 2) |