原子操作是在多线程程序中“最小的且不可并行化的”操作,意味着多个线程访问同一个资源时,有且仅有一个线程能对资源进行操作。通常情况下原子操作可以通过互斥的访问方式来保证,例如Linux下的互斥锁(mutex),Windows 下的临界区(Critical Section)等。
一、多线程下共享变量的问题
(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
的时候断言失败。
解决方案
一个比较稳妥的办法就是对于共享变量的访问进行加锁,加锁可以保证对临界区的互斥访问,例如第一种场景如果加锁后再执行i++ 然后解锁,则同一时刻只会有一个线程在执行i++ 操作。另外,加锁的内存语义能保证一个线程在释放锁前的写入操作一定能被之后加锁的线程所见(即有happens before 语义),可以避免第二种场景中读取到错误的值。
#include <pthread.h>
#include <iostream>
using namespace std;
int64_t total=0;
pthread_mutex_t m=PTHREAD_MUTEX_INITIALIZER;
//线程函数,用于累加
void* threadFunc(void* args)
{
int64_t endNum=*(int64_t*)args;
for(int64_t i=1;i<=endNum;++i)
{
pthread_mutex_lock(&m);
total+=i;
pthread_mutex_unlock(&m);
}
}
int main()
{
int64_t endNum=100;
pthread_t thread1ID=0,thread2ID=0;
//创建线程1
pthread_create(&thread1ID,NULL,threadFunc,&endNum);
//创建线程2
pthread_create(&thread2ID,NULL,threadFunc,&endNum);
//阻塞等待线程1结束并回收资源
pthread_join(thread1ID,NULL);
//阻塞等待线程2结束并回收资源
pthread_join(thread2ID,NULL);
cout<<"total="<<total<<endl; //10100
}
C++11 通过引入原子类型帮助开发者轻松实现原子操作:
#include <atomic>
#include <thread>
#include <iostream>
using namespace std;
atomic_int64_t total = 0; //atomic_int64_t相当于int64_t,但是本身就拥有原子性
//线程函数,用于累加
void threadFunc(int64_t endNum)
{
for (int64_t i = 1; i <= endNum; ++i)
{
total += i;
}
}
int main()
{
int64_t endNum = 100;
thread t1(threadFunc, endNum);
thread t2(threadFunc, endNum);
t1.join();
t2.join();
cout << "total=" << total << endl; //10100
}
二、C++11中的原子量
C++11标准在标准库atomic头文件提供了模版atomic<>来定义原子量:
template< class T >
struct atomic;
它提供了一系列的成员函数用于实现对变量的原子操作,例如读操作load,写操作store,以及CAS操作compare_exchange_weak/compare_exchange_strong等。而对于大部分内建类型,C++11提供了一些特化:
std::atomic_bool std::atomic<bool>
std::atomic_char std::atomic<char>
std::atomic_schar std::atomic<signed char>
std::atomic_uchar std::atomic<unsigned char>
std::atomic_short std::atomic<short>
std::atomic_ushort std::atomic<unsigned short>
std::atomic_int std::atomic<int>
std::atomic_uint std::atomic<unsigned int>
std::atomic_long std::atomic<long>
······
//更多类型见:http://en.cppreference.com/w/cpp/atomic/atomic
可以看到atomic是一个模版,那么也就意味着我们可以把自定义类型变成原子变量。但是是否任意类型都可以定义为原子类型呢?当然不是,cppreference中的描述是必须为TriviallyCopyable类型。这个连接为TriviallyCopyable的详细定义:
http://en.cppreference.com/w/cpp/concept/TriviallyCopyable
一个比较简单的判断标准就是这个类型可以用std::memcpy按位复制,例如下面的类:
class {
int x;
int y;
}
这个类是一个TriviallyCopyable类型,然而如果给它加上一个虚函数:
class {
int x;
int y;
virtual int add ()
{
return x + y;
}
}
这个类便不能按位拷贝了,不满足条件,不能进行原子化。
如果一个类型能够满足atomic模版的要求,可以原子化,它就不用进行加锁操作了,因而速度更快吗?依然不是,atomic有一个成员函数is_lock_free,这个成员函数可以告诉我们到底这个类型的原子量是使用了原子CPU指令实现了无锁化,还是依然使用的加锁的方式来实现原子操作。不过不管是否用锁来实现,atomic的使用方式和表现出的语义都是没有区别的。具体用哪种方式实现C++标准并没有做约束(除了std::atomic_flag特化要求必须为lock free),跟平台有关。
三、C++11的六种内存序
前面我们解决i++问题的时候已经使用过原子量的写操作load将原子量赋值,实际上成员函数还有另一个参数:
void store( T desired, std::memory_order order = std::memory_order_seq_cst )
这个参数代表了该操作使用的内存序,用于控制变量在不同线程见的顺序可见性问题,不只load,其他成员函数也带有该参数。c++11提供了六种内存序供选择,分别为:
typedef enum memory_order {
memory_order_relaxed,
memory_order_consume,
memory_order_acquire,
memory_order_release,
memory_order_acq_rel,
memory_order_seq_cst
} memory_order;
之前在场景2中,因为指令的重排导致了意料之外的错误,通过使用原子变量并选择合适内存序,可以解决这个问题。下面先来看看这几种内存序
memory_order_release/memory_order_acquire
内存序选项用来作为原子量成员函数的参数,memory_order_release用于store操作,memory_order_acquire用于load操作,这里我们把使用了memory_order_release的调用称之为release操作。从逻辑上可以这样理解:release操作可以阻止这个调用之前的读写操作被重排到后面去,而acquire操作则可以保证调用之后的读写操作不会重排到前面来。听起来有种很绕的感觉,还是以一个例子来解释:假设flag为一个 atomic特化的bool 原子量,a为一个int变量,并且有如下时序的操作:
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) |
实际上这就是把我们上文场景2中的flag变量换成了原子量,并用其成员函数进行读写。在这种情况下的逻辑顺序上,step1不会跑到step2后面去,step4不会跑到step3前面去。这样一来,实际上我们就已经保证了当读取到flag为true的时候a一定已经被写入为1了,场景2得到了解决。换一种比较严谨的描述方式可以总结为:
- 对于同一个原子量,release操作前的写入,一定对随后acquire操作后的读取可见。
这两种内存序是需要配对使用的,这也是将他们放在一起介绍的原因。还有一点需要注意的是:只有对同一个原子量进行操作才会有上面的保证,比如step3如果是读取了另一个原子量flag2,是不能保证读取到a的值为1的。
memory_order_release/memory_order_consume
memory_order_release还可以和memory_order_consume搭配使用。memory_order_release操作的作用没有变化,而memory_order_consume用于load操作,我们简称为consume操作,comsume操作防止在其后对原子变量有依赖的操作被重排到前面去。这种情况下:
- 对于同一个原子变量,release操作所依赖的写入,一定对随后consume操作后依赖于该原子变量的操作可见。
这个组合比上一种更宽松,comsume只阻止对这个原子量有依赖的操作重拍到前面去,而非像aquire一样全部阻止。将上面的例子稍加改造来展示这种内存序,假设flag为一个 atomic特化的bool 原子量,a为一个int变量,b、c各为一个bool变量,并且有如下时序的操作:
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) |
step4使得c依赖于flag,当step4线程B读取到flag的值为true的时候,由于flag依赖于b,b在之前的写入是可见的,此时b一定为true,所以step6、step7的断言一定会成功。而且这种依赖关系具有传递性,假如b又依赖与另一个变量d,则d在之前的写入同样对step4之后的操作可见。那么a呢?很遗憾在这种内存序下a并不能得到保证,step5的断言可能会失败。
memory_order_acq_rel
这个选项看名字就很像release和acquire的结合体,实际上它的确兼具两者的特性。这个操作用于“读取-修改-写回”这一类既有读取又有修改的操作,例如CAS操作。可以将这个操作在内存序中的作用想象为将release操作和acquire操作捆在一起,因此任何读写操作的重拍都不能跨越这个调用。依然以一个例子来说明,flag为一个 atomic特化的bool 原子量,a、c各为一个int变量,b为一个bool变量,并且刚好按如下顺序执行:
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 (false == flag.load(memory_order_acquire) | |
8 | assert(c == 2) |
由于memory_order_acq_rel同时具有memory_order_release与memory_order_acquire的作用,因此step2可以和step5组合成上面提到的release/acquire组合,因此step6的断言一定会成功,而step5又可以和step7组成release/acquire组合,step8的断言同样一定会成功。
memory_order_seq_cst
这个内存序是各个成员函数的内存序默认选项,如果不选择内存序则默认使用memory_order_seq_cst。这是一个“美好”的选项,如果对原子变量的操作都是使用的memory_order_seq_cst内存序,则多线程行为相当于是这些操作都以一种特定顺序被一个线程执行,在哪个线程观察到的对这些原子量的操作都一样。同时,任何使用该选项的写操作都相当于release操作,任何读操作都相当于acquire操作,任何“读取-修改-写回”这一类的操作都相当于使用memory_order_acq_rel的操作。
memory_order_relaxed
这个选项如同其名字,比较松散,它仅仅只保证其成员函数操作本身是原子不可分割的,但是对于顺序性不做任何保证。
代价
总的来讲,越严格的内存序其性能开销会越大。对于我们常用的x86处理器而言,在处理器层级本身就支持release/acquire语义,因此release与acquire/consume都只影响编译器的优化,而memory_order_seq_cst还会影响处理器的指令重排。