本文并非原创,转载于知乎文章C++11 - atomic类型和内存模型,请支持原创!!!
追加:经过几天的查阅和理解,本人也写了一篇文章来彻底理解C++的内存模型,请看:C++11内存模型完全解读-从硬件层面和内存模型规则层面双重解读
概述:
本文是两本书的读书笔记,一本是《深入理解C++11 - C++11新特性解析与应用》(下文简称书1),另外一本是《C++ Concurrency In Action》(英文版)(下文简称书2)。作者在Windows平台下使用多线程技术进行工业自动化领域开发工作,深知多线程程序开发、测试和调试及调优,是件痛苦的事情——深夜调试/失眠、抓狂……
2000年之后,依靠CPU频率提升加速软件运行速度的免费午餐结束了,随之而来的是多核处理器的发展风潮。相应地,主流芯片厂商及编译器开发厂商都开始推广适用于多核处理器的多线程编程模型,编程语言逐渐也开始向并行化的编程方式发展。但是C++98/03标准中,并没有多线程相关特性供大家使用,C++11标准到来之前,我们不得不借助两种方法进行多线程开发:一是使用编译器厂商对C++语言的拓展进行多线程开发工作——POSIX Pthread、Windows API;二是借助Boost,ACE等库。但是缺乏统一的标准,缺乏线程感知的内存模型(thread-aware memory model), 意味着想要借助于处理器硬件知识极限压榨程序性能,和想要编写跨平台的多线程程序,都困难不小。
C++11 标准中,相当大的一个变化就是引入了多线程的支持,这使得用C/C++进行线程编程时,不必依赖第三方库和标准。
而C++11对多线程的支持,最为重要的部分,就是在原子操作中引入了原子类型的概念。
本文重点在于梳理为什么需要引入原子类型和内存模型,以及由此带来的好处。下篇文章将着重描述细节性要点。
注:本文中所涉及的示例代码均出自上述两本书中。
何谓原子操作?
所谓原子操作,就是多线程程序中“最小的且不可并行化的”操作。对于在多个线程间共享的一个资源而言,这意味着同一时刻,多个线程中有且仅有一个线程在对这个资源进行操作,即互斥访问。提到“互斥”访问,熟悉多线程开发的同学可能立即想到Windows平台下使用的临界区/CRITICAL_SECTION、互斥体/Mutex。实现互斥通常需要平台相关的特殊指令,在C++11标准之前,这意味着需要在C/C++代码中嵌入平台相关的内联汇编代码。 平台相关意味着:1.你必须了解平台相关的编译器扩展;2.无法跨平台运行你的多线程程序。
来看下面的例子:
书1 - 197页,代码清单6-18
#include <pthread.h>
#include <iostream>
using namespace std;
static long long total=0;
pthread_mutex_t m=PTHREAD_MUTEX_INITIALIZER;
void*func(void*)
{
long long i;
for(i=0;i<100000000LL;i++)
{
pthread_mutex_lock(&m);
total+=i;
pthread_mutex_unlock(&m);
}
}
int main()
{
pthread_t thread1,thread2;
if(pthread_create(&thread1,NULL,&func,NULL))
{
throw;
}
if(pthread_create(&thread2,NULL,&func,NULL))
{
throw;
}
pthread_join(thread1,NULL);
pthread_join(thread2,NULL);
cout<<total<<endl;//9999999900000000
return 0;
}
//编译选项:g++6-3-1.cpp-lpthread
代码中,我们启动两个线程来更新变量total的值,为了防止数据竞争问题,我们需要使用pthread_mutex_t类型的互斥锁,来保证两个线程互斥地访问total,从而得到total的正确结果。这个例子演示了多线程开发的基本问题:
- 找到多个线程共享的资源/数据(飞机上洗手间里的马桶)。操作这些资源/数据的代码称之为临界区(洗手间)。
- 线程进入临界区并加锁,防止其他线程进入临界区(进房间后立即锁门,其他人排队)。
- 更新/操作共享的资源/数据(…………)
- 线程离开临界区,独享权让给其他等待的线程(解锁,开门。下一位……)。
如果多个线程各干各的活,没有共享的资源/数据,老死不相往来,那就没有临界区、互斥锁等所有这些破事了,完美!
但是!
对于熟悉单线程开发的同学来说,互斥锁的管理无疑是个负担。需要说明的是,上述代码仅仅演示最基本、最简单的情形。复杂、特殊情况下,这个管理负担还是挺重的,这里就不展开讨论。
“就两个线程就共享了一个数据,需要承担互斥锁的管理负担?!”相较于单线程程序,虽然只多写了两行代码,但是程序员都是懒惰的——能不写的代码坚决不写,能少写的坚决不多写(在保证代码可读性的前提下),这也是优秀程序员的标准做法。
另外一个问题是跨平台——上述代码在LINUX平台下用g++编译运行的,但是在Windows平台上,得做一番改动/重写才能运行起来。Once for all? ! No way!
C++11标准从不同的视角看待这个问题:需要同步的总是资源/数据,而不是代码。因此C++11对数据进行了更为良好的抽象,引入"原子数据类型"/atomic类型,以达到对开发者掩盖互斥锁、临界区的目的。要知道,这些临界区、互斥锁才是平台相关的东西。来看下面的示例代码。
书1 - 198页,代码清单6-19
#include<atomic>
#include<thread>
#include<iostream>
using namespace std;
std::atomic_llong total{ 0 };//原子数据类型
void func(int)
{
for (long long i = 0; i<100000000LL; ++i)
{
total += i;
}
}
int main()
{
thread t1(func, 0);
thread t2(func, 0);
t1.join();
t2.join();
cout<<total<<endl;//9999999900000000
return 0;
}
//编译选项:g++ -std=c++11 6-3-2.cpp-lpthread
代码中,将total定义为“原子数据类型”:atomic_llong, 该数据类型长度等于C++11中内置数据类型long long。可以看到,程序员不需要为原子数据类型显示地调用加锁、解锁的API,线程就能够对共享数据total进行互斥地访问。太容易了,又可以“偷懒”了!
上述代码,书1作者在Linux系统下开发,用g++编译器编译运行的。代码不做任何改动,我在Windows系统下使用Visual Studio 2015编译运行也OK的。
那么问题来了:既然原子操作都是平台相关的,那么atomic数据类型又如何帮助我们“掩盖”这种平台相关性呢?C++11 对常见的原子操作进行了抽象,定义出统一的接口,并根据编译选项/环境产生平台相关的实现。新标准将原子操作定义为atomic模板类的成员函数,囊括了绝大多数典型的操作——读、写、比较、交换等。
原子数据类型/atomic类型
让我们先来看一下atomic模板类:
template <class T> struct atomic;
//example
#include<atomic>
void test()
{
std::atomic_int nThreadData; // std::atomic_int <----> std::atomic<int>
nThreadData = 10;
nThreadData.store(10);
//TODO: use nThreadData here;
}
对于内置型数据类型,C11和C++11标准中都已经提供了实例化原子类型,如下表所示:
表 #1
原子类型名称 | 对应内置类型名称 |
---|---|
atomic_bool | bool |
atomic_char | char |
atomic_schar | signed char |
atomic_uchar | unsigend char |
atomic_int | int |
atomic_uint | unsigned int |
atomic_short | short |
atomic_ushort | unsigned short |
atomic_long | long |
atomic_ulong | unsigned long |
atomic_llong | long long |
atomic_ullong | unsigned long long |
atomic_char16_t | char16_t |
atomic_char32_t | char32_t |
atomic_wchar_t | wchar_t |
atomic类型原子操作接口如下:
表 #2
操作 | atomic_flag | atomic_bool | atomic-integral-type | atomic<bool> | atomic<T*> | atomic<integral-type> | atomic<class-type> |
---|---|---|---|---|---|---|---|
test_and_set | Y | ||||||
clear | Y | ||||||
is_lock_free | y | y | y | y | y | y | |
load | y | y | y | y | y | y | |
store | y | y | y | y | y | y | |
exchange | y | y | y | y | y | y | |
compare_exchange_weak+strong | y | y | y | y | y | y | |
fetch_add, += | y | y | y | ||||
fetch_sub, -+ | y | y | y | ||||
fetch_or, |= | y | y | |||||
fetch_and, &= | y | y | |||||
fetch_xor, ^= | y | y | |||||
++, - - | y | y | y | y |
这些接口函数的说明和示例代码,请参考如下链接:
cppreference.com
cplusplus.com
总体而言,在多线程程序中使用这些内置数据类型对应的atomic类型,还是不难的,应付一般的多线程问题还是得心应手的。有的同学会立即想到,线程间需要交互好多数据?用自定义数据类型/UDT去实例化atomic模板类, 岂不是可以大大缩减开发的工作量?请注意表#2最后一列,class-type就是指自定义数据类型/UDT.
但是!UDT要满足以下5个条件,才可作为模板参数去实例化atomic模板:
#include <atomic>
#include <type_traits>
using namespace std;
struct MY_UDT
{
//TODO:data member here
};
int main()
{
auto ret = std::is_trivially_copyable<MY_UDT>::value;
ret = std::is_copy_constructible<MY_UDT>::value;
ret = std::is_move_constructible<MY_UDT>::value;
ret = std::is_copy_assignable<MY_UDT>::value;
ret = std::is_move_assignable<MY_UDT>::value;
return 0;
}
MY_UDT是用户自定义数据类型,上述代码中,5个ret值必须都为true。也就是说对UDT的拷贝构造、赋值构造、移动赋值等各方面都提出了要求……“要求这么高!?”用了atomic模板类,既不劳烦你写加锁/解锁代码,又不用你考虑跨平台那档子事,多好! 哪有无缘无故的好事?总要付出一定代价的。
内存模型
通常情况下,内存模型是一个硬件上的概念,表示的是机器指令(或者将其视为汇编指令也可以)是以什么样的顺序被处理器执行的。现代的处理器并不是逐条处理机器指令的。
#include <thread>
#include <atomic>
#include <iostream>
using namespace std;
atomic<int> a{0};
atomic<int> b{0};
int ValueSet(int)
{
int t=1;
a=t;
b=2;
return 0;
}
平淡无奇的代码。指令“t=1;a=t;b=2”,其伪汇编代码如下:
1: Loadi reg3, 1; # 将立即数1放入寄存器reg3
2: Move reg4, reg3; # 将reg3的数据放入reg4
3: Store reg4, a; # 将寄存器reg4中的数据存入内存地址a
4: Loadi reg5, 2; # 将立即数2放入寄存器reg5
5: Store reg5, b; # 将寄存器reg5中的数据存入内存地址b
按照通常的理解,指令总是按照1->2->3->4->5顺序执行的,如果处理器是按照这个顺序执行的,我们称这样的内存模型为强顺序的(strong ordered)。 这种执行方式下,指令3总是先于指令5执行,即a赋值在前,b赋值在后。
但是指令1、2、3(a赋值)和指令4、5(b赋值)毫不相干。一些处理器可能将指令乱序执行,比如按照1->4->2->5->3这样的顺序(超标量流水线,即一个时钟周期里发射多条指令)。如果指令是“乱序”执行的,我们称这样的内存模型为弱顺序的(weak ordered)。这种执行方式下,指令5可能先于指令3被执行,即可能b赋值在前,a赋值在后。
弱顺序的内存模型的好处在哪里?可以进一步挖掘指令中的并行性,提高指令执行的性能。
在单线程程序中,我管你内存模型是强顺序的还是弱顺序的,管你是顺序执行还是乱序执行的,反正最终结果是a等于1,b等于2。
但是在多线程情况下,“乱序”执行可能就会造成问题。
书1 - 203页,代码清单 6-21
#include <thread>
#include <atomic>
#include <iostream>
using namespace std;
atomic<int> a{0};
atomic<int> b{0};
int ValueSet(int)
{
int t=1;
a=t;
b=2;
}
int Observer(int)
{
cout<<"("<<a<<","<<b<<")"<<endl; //可能有多种输出
}
int main()
{
thread t1(ValueSet,0);
thread t2(Observer,0);
t1.join();
t2.join();
cout<<"Got("<<a<<","<<b<<")"<<endl; //Got(1,2)
}
线程Observer只是试图一窥线程ValueSet的执行情况,就看看而已,所以无论ValueSet的代码是顺序执行的还是乱序执行的,都无所谓,无非就是a,b输出值的顺序可能是(0,0),或者(1,2),或者(1,0),甚至(0,2),但是最终的输出结果都是(1,2)。
通常情况下,如果编译器认定a、b的赋值语句的执行先后顺序对输出结果没有任何的影响的话,则可以依情况将指令重排序(reorder)以提高性能。而如果a、b赋值语句的执行顺序必须是a先b后,则编译器则不会执行这样的优化。
试想一下,如果Observer里的操作结果严重依赖于ValueSet中指令的执行顺序,会怎么样?代码字面上的执行顺序都可能被打乱了,Observer不出问题才怪!
你一会想顺序执行,一会又想“乱序”执行,更有甚者,还想对“乱”的程度分等级……如何提供这种灵活性呢?
在C++11标准中,设计者给出的解决方式是让程序员为原子操作指定所谓的内存顺序:memory_order。
书1 - 208页,代码清单 6-23
#include <thread>
#include <atomic>
#include <iostream>
using namespace std;
atomic<int> a{0};
atomic<int> b{0};
int ValueSet(int)
{
int t=1;
a.store(t,memory_order_relaxed);
b.store(2,memory_order_relaxed);
}
int Observer(int)
{
cout<<"("<<a<<","<<b<<")"<<endl; //可能有多种输出
}
int main()
{
thread t1(ValueSet,0);
thread t2(Observer,0);
t1.join();
t2.join();
cout<<"Got("<<a<<","<<b<<")"<<endl; //Got(1,2)
return 0;
}
//编译选项:g++ -std=c++11 6-3-6.cpp-lpthread
注意memory_order_relaxed
的使用,其实际意义后面再解释。对原子类型而言,赋值“=”和调用store接口函数,作用都是一样的,只不过,除了要写入的值外,store还接受另外一个名为memory_order的枚举值。来看下std::atomic<T>::store
接口的定义:
void atomic<T>::store(T desired, std::memory_order order = std::memory_order_seq_cst) volatile noexcept;
memory_order参数的默认值是std::memory_order_seq_cst
。实际上,atomic类型的其他原子操作接口都有memory_order这个参数,而且默认值都是std::memory_order_seq_cst
。
枚举memory_order如下:
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;
memory_order的各个枚举值的定义规则如下:
枚举值 | 定义规则 |
---|---|
memory_order_relaxed | 不对执行顺序做任何保证 |
memory_order_acquire | 本线程中,所有后续的读操作必须在本条原子操作完成后执行 |
memory_order_release | 本线程中,所有之前的写操作完成后才能执行本条原子操作 |
memory_order_acq_rel | 同时包含memory_order_acquire和memory_order_release标记 |
memory_order_consume | 本线程中,所有后续的有关本原子类型的操作,必须在本条原子操作完成之后执行 |
memory_order_seq_cst | 全部存取都按顺序执行 |
让我们来逐一检视这些枚举值的意义。
注:请对”定义规则“的描述进行咬文嚼字、抠字眼、逐字、反复理解。
顺序一致内存顺序/memory_order_seq_cst
定义规则:全部存取都按照顺序执行。
memory_order_seq_cst
表示该原子操作必须顺序一致的,这是C++11中所有atomic原子操作的默认值。这样来理解“顺序一致”:即代码在线程中运行的顺序与程序员看到的代码顺序一致。也就是说,用此值提示编译器 “不要给我重排序指令,不要整什么指令乱序执行,就按照我代码的先后顺序执行机器指令”。在示例代码中,a的赋值语句先于b的赋值语句执行,这种称之为”先于发生(happens-before)“关系。用memory_order_seq_cst
可以确保这种happens_before
关系。
松散内存顺序/memory_order_relaxed
定义规则:不对执行顺序做任何保证。
表示该原子操作指令可以任由编译器重排或者由处理器乱序执行。就是说"想怎么乱就怎么乱吧,不管了,只要能提高指令执行效率"。代码清单6-23中使用的就是松散内存模型,在Observer中打印出(0,2)这样的结果也是合理的——把我代码中的顺序都彻底整反了!
书1 - 210页,代码清单6-24
#include <thread>
#include <atomic>
#include <iostream>
using namespace std;
atomic<int> a;
atomic<int> b;
int Thread1(int)
{
int t=1;
a.store(t,memory_order_relaxed);
b.store(2,memory_order_relaxed);
}
int Thread2(int)
{
while(b.load(memory_order_relaxed)!=2);//自旋等待
cout<<a.load(memory_order_relaxed)<<endl;
}
int main()
{
thread t1(Thread1,0);
thread t2(Thread2,0);
t1.join();
t2.join();
return 0;
}
//编译选项:g++ -std=c++11 6-3-7.cpp-lpthread
上述代码中,我们用memory_order_relaxed
的初衷是不希望完全禁用原子类型的优化。”自旋等待“那行代码的真实用意是:先自旋等待b被赋值为2,随后再将a的值输出。但是按照松散内存顺序,a.store 和 b.store指令的先后顺序不能保证了,b.store可能先被执行,因此a的输出值可能是0,也可能是1。 这些不是代码作者想要的。
release-acquire内存顺序
memory_order_acquire
规则定义:本线程中,所有后续的读操作,必须在本条原子操作完成后执行。(本线程中,我先读,你们后读……)
memory_order_release
规则定义:本线程中,所有之前的写操作完成后,才能执行本原子操作。(在本线程中,你们先写,我最后写……)
上面讲的顺序一致和松散方式对应着两个极端——一个是严格禁止”乱“,一个是允许随便”乱“。但是现实的问题是:严格禁止”乱“,指令执行不够快;允许随便”乱“,又得不到正确结果。
”能搞组合贷不?“
书1 - 211,代码清单6-25
#include <thread>
#include <atomic>
#include <iostream>
using namespace std;
atomic<int> a;
atomic<int> b;
int Thread1(int)
{
int t=1;
a.store(t,memory_order_relaxed);
b.store(2,memory_order_release); //本原子操作前所有的写原子操作必须完成
}
int Thread2(int)
{
while(b.load(memory_order_acquire)!=2); //本原子操作必须完成才能执行之后所有的读原子操作
cout<<a.load(memory_order_relaxed)<<endl; //1
}
int main()
{
thread t1(Thread1,0);
thread t2(Thread2,0);
t1.join();
t2.join();
return 0;
}
//编译选项:g++ -std=c++11 6-3-8.cpp-lpthread
注意:thread1中,b.store采用了memory_order_release
内存顺序,保证了本线程中,本原子操作前的所有写操作都必须完成,也即a.store必须发生于b.store之前。在thread2中,b.load采用了memory_order__acquire
内存顺序,保证了本线程中,本原子操作必须先完成,才能执行之后所有的读原子操作,即b.load必须先于a.load执行。
release-consume内存顺序
memory_order_consume
规则定义:本线程中,所有后续的有关本算子类型的操作,必须在本条原子操作完成之后执行。(本线程中,我只关心我自己,当我用memory_order_consume
时,后面所有对我的读写操作都不能被提前执行……)
相比于memory_order_acquire
,memory_order_consume
进一步放松了依赖关系。大家发现没有,前面讲的几种内存顺序都是在操控/安排多个atomic数据之间的读写顺序,而memory_order_consume
仅仅考虑对一个atomic数据的读写顺序。
书1 - 213页,代码清单6-26
#include <thread>
#include <atomic>
#include <cassert>
#include <string>
using namespace std;
atomic<string*>ptr;
atomic<int> data;
void Producer()
{
string* p=new string("Hello");
data.store(42,memory_order_relaxed);
ptr.store(p,memory_order_release);
}
void Consumer()
{
string* p2;
while(!(p2=ptr.load(memory_order_consume)));
assert(*p2=="Hello"); //总是相等
assert(data.load(memory_order_relaxed)==42); //可能断言失败
}
int main()
{
thread t1(Producer);
thread t2(Consumer);
t1.join();
t2.join();
}
//编译选项:g++ -std=c++11 6-3-9.cpp-lpthread
注意,Consumer函数中第一个assert语句:对指针p2进行解引用操作,其实质是在ptr上调用load。 我们可以保证第一个assert不会被触发,因为通过memory_order_consume的内存顺序,保证while语句中ptr.load必须发生在*p2这个解引用操作(实际上涉及读取指针ptr.load的操作)之前。第二个断言可能失败,原因自行分析下。
请仔细阅读、深入理解各个内存顺序的规则定义。
总结
这不是一篇入门级的文章,重点在于理解:
为什么要引入atomic类型,给开发者带来了哪些好处。
atomic类型的原子操作接口中,memory_order参数是干嘛用的。
最后,用书1中的一段话最为结束:
原子操作彻底宣告C++11来到了多线程和并行编程的时代。相对于偏于底层的pthread库,C++通过定义原子类型的方式,轻松地化解了互斥访问共享数据的难题。不过C++也延续了其易于学习难于精通的特性,虽然atomic/原子类型使用上较为简单,但其函数接口(原子操作)却可以有不同的内存顺序。C++11从各种不同的平台上抽象出了一个软件的内存模型,并以内存顺序进行描述,以使得想进一步挖掘并行系统性能的程序员有足够简单的手段来完成以往只能通过内联汇编来完成的工作。
C++11中这些内存顺序相关的设计,主要还是为了从各种繁杂不同的平台上抽象出独立于硬件平台的并行操作。对于我们日常的开发工作,默认的顺序一致内存顺序memory_order_seq_cst足可以应付了,但是开发者想让多线程程序获得更好的性能的话,尤其是在一些弱内存顺序的平台上,比如PowerPC,建立原子操作间的内存顺序还是很有必要的,因为着能带来极大的性能提升,这也是一些弱一致性内存模型平台的优势。
但对于并行编程来说,可能最根本的,还是思考如何将大量计算的问题,按需分解成多个独立的、能够同时运行的部分,并找出真正需要在线程间共享的数据,实现为C++11的原子类型。虽然有了原子类型的良好设计,实现这些都可以非常的便捷,但并不是所有的问题或者计算都适合用并行计算来解决,对于不适用的问题,强行用并行计算来解决会收效甚微,甚至起到相反效果。因此在决定使用并行计算解决问题之前,程序员必须要有清晰的设计规划。而在实现了代码并行后,进一步使用一些性能调试工具来提高并行程序的性能也是非常必要的。
摘自:《深入理解C++11:C++11新特性解析与应用》 — (加)Michael Wong IBM XL编译器中国开发团队