引子

先看一段C++初学者都会写的代码

1
2
3
4
5
6
7
8
9
#include <iostream>
#include <string>

int main(int argc, const char * argv[]) {
std::string str = "Hello, World!\n";
std::cout << str; // 1
std::cout << "Hello, World!\n"; // 2
return 0;
}

试着回答下面几个问题:

  • 如果之前学过其他语言但是没接触过C++,代码中<<的写法看起来很怪异,它是怎么工作的?
  • 1 & 2 的写法有什么不同?它们调用的是同一个方法么?

回答

C++中有一个神奇的东西叫做操作符重载,这个操作允许用户针对某一些需要的类型自定义操作符。比如说下面这个数据类型就重载了+号操作符,从而允许更加简单的代码书写形式。这里先不讨论操作符重载带来的问题,但是这个确实是在标准库中被普遍使用的,这里看到的<<也是属于操作符重载。

1
2
3
4
5
6
7
8
class Data
{
public:
Data& operator+(int i){std::cout << "Data+" << i; return *this;}
};

Data d;
d = d + 1;

我们可以在<ostream>中看到basic_ostream预定义了很多对于operator<<的操作符重载,可以简单的看一下,这里只摘录了一些,头文件中实际定义了更多类型。

1
2
3
4
5
6
7
8
9
// 27.7.2.6 Formatted output:
basic_ostream& operator<<(basic_ostream& (*pf)(basic_ostream&));
basic_ostream& operator<<(basic_ios<charT, traits>& (*pf)(basic_ios<charT,traits>&));
basic_ostream& operator<<(ios_base& (*pf)(ios_base&));
basic_ostream& operator<<(bool n);
basic_ostream& operator<<(short n);
basic_ostream& operator<<(unsigned short n);
basic_ostream& operator<<(int n);
...

那么1和2是否都调用了basic_ostream中预定的<<呢?2确实是调用了basic_ostream中的方法,2的代码可以改写成下面这种形式。

1
2
3
std::cout << "Hello, World!\n";
// 相当于
std::cout.operator<<("Hello, World!\n");

但1并不是调用basic_ostream中的方法。basic_ostream中预定义的都是系统自带的类型,而std::string则是标准库自定义的类型,basic_ostream并不知道它的存在。那么std::cout << str;这行代码是怎么工作的呢?

我这里先给出答案,然后在慢慢的解释是如何工作的

1
2
3
4
5
std::string str = "Hello, World!\n";
std::cout << str;
// 相当于
operator<<(std::cout, str);
//

Argument-Dependent Lookup(实参相关的查找)

中文翻译参考自C++ Primer,下面简称ADL。这个查找规则简单的可以归纳为:

当我们给函数传递一个类类型的对象时,出了在常规的作用域查找外还会查找实参类所属的命名空间。这个规则对于传递类的引用或指针的调用同样有效。 - 《C++ Primer 5th》

举个例子就能够很直观的看到这个效果。

1
2
3
4
5
6
7
8
9
10
namespace NS 
{
class A {};
void f( A &a, int i) {}
}
int main()
{
NS::A a;
f( a, 0 ); //calls NS::f
}

有了这个规则以后我们就很好理解本文开头的第一个例子如何工作的。<string>头文件中定一个一个operator<<的操作符重载并且接受basic_ostream为第一个参数,std::string为第二个参数。当编译器在basic_ostream中找不到对应的函数的时候,它便开始在std::string的命名空间下查找对应的方法。然后它找到中有一个符合要求的方法,然后就调用了这个方法。关于C++的命名查找规则是一个巨复杂的东西,有兴趣可以看看这个页面(Name Lookup

同理,我们可以为我们自己的类型自定义这个操作符重载,这样我们在输出的时候就可以更加的方便。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Person
{
public:
std::string name;
int age;
};

std::ostream& operator<<(std::ostream& os,
const Person &p)
{
os << "name: " << p.name << " age: " << p.age;
return os;
}

Person p{"john", 100};
std::cout << p << std::endl;

关于ADL我们需要注意什么

能够被ADL找到的非成员方法应该被视为类的设计的一部分,如果你考虑写类似的方法如果不是为了ADL的话应该避免这种写法。

ADL能够极大的方便我们编码时候的方法调用,从而提高代码的可读性。STL库内使用的swap方法也广泛的使用了这个技术。关于swap的话题,改天单独讨论。

参考资料

这本书前面几章介绍了C++并发编程中使用到的一些具体工具,掌握了这些具体工具并不能够保证你能够写出优秀的并发代码。

这章就是介绍如何设计你的并发代码、在写并发代码的时候需要考虑的一些因素和注意的地方。从一个更高的角度去思考如何写并发代码。

如何给线程分配工作

当我们起多个线程去做并行计算的时候我们需要把原来一个线程处理的数据分配给不同的线程。那么如何切分数据就有很多种选择:

简单粗暴的根据数量切分

比如有100个数据,我们起了m个线程那么每个线程处理100/m个数据。如果有必要的话最后将处理的结果进行合并。打个比方,我们现在要洗n个碗,那么我们可以把线程想象成洗碗工,如何给洗碗工分配工作,假设每个洗碗工洗碗速度差不多,那么只需要把盘子分成若干份分配给各个洗碗工即可。(当然你要有足够多的洗碗池)

Alt text

递归的切分

有很多算法无法在一开始就很好的对数据进行切分,那么我们就可以用递归的方法。但是递归的话就不能暴力的去起线程,而是可以用std::async让线程库去决定是否在另外线程执行。

Alt text

按照任务类型进行切分

还是用厨房打比方,如果现在收到了一个订单需要做100份宫保鸡丁。如果按照第一种方法的话就是把100道分给n个厨师,每个厨师负责做100/n份。那么这个有什么问题呢?每个厨师都需要去洗菜、切菜、炒菜、装盘等。每一道工具之间会浪费厨师的时间,而且如果厨房不是足够大的话,厨师之间可能会出现竞争关系,比如说只有3个炒锅、那么有一些厨师可能需要排队才能用到炒锅。

现实中的厨房肯定不是这样的,每一个环节都是有专门的人负责的。洗菜工、打荷、厨师。这样流水线的工作可以提高效率。

按照任务类型的切分方法跟厨房的这个思想是一样的,每个线程都有自己的指责,接受输入处理相对独立的功能。

影响并发代码性能的因素

在说影响性能的因素之前,先来看一张表Latency Numbers Every Programmer Should Know

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
Latency Comparison Numbers
--------------------------
L1 cache reference 0.5 ns
Branch mispredict 5 ns
L2 cache reference 7 ns 14x L1 cache
Mutex lock/unlock 25 ns
Main memory reference 100 ns 20x L2 cache, 200x L1 cache
Compress 1K bytes with Zippy 3,000 ns 3 us
Send 1K bytes over 1 Gbps network 10,000 ns 10 us
Read 4K randomly from SSD* 150,000 ns 150 us ~1GB/sec SSD
Read 1 MB sequentially from memory 250,000 ns 250 us
Round trip within same datacenter 500,000 ns 500 us
Read 1 MB sequentially from SSD* 1,000,000 ns 1,000 us 1 ms ~1GB/sec SSD, 4X memory
Disk seek 10,000,000 ns 10,000 us 10 ms 20x datacenter roundtrip
Read 1 MB sequentially from disk 20,000,000 ns 20,000 us 20 ms 80x memory, 20X SSD
Send packet CA->Netherlands->CA 150,000,000 ns 150,000 us 150 ms

计算机的计算发生在CPU,而数据则来自内存、磁盘甚至网络上。从上表可以看到,CPU访问的数据来源对于它的访问速度差距巨大。从最快的L1缓存到网络访问,差距有3亿倍之巨。

下面要说的几点,基本上可以理解为需要经常从“很远的”读取数据。

线程数量

线程数量并非越多越好,当线程的数量太多,而且都有实际运算需要进行的时候。操作系统会进行调度来保证线程之间的公平,而切换线程的时候会出现上下文切换。也就是说要把当前线程相关的上下文存下来,然后把另一个线程的上下文载入,这回带来一定的开销。当线程数量增加的时候,带来的开销也就越来越大。

Data Contention(数据竞争)

可以理解为多个线程同时去操作一个内存,至少有一个去写这块内存。如果所有的操作都是只读的话就没有问题,因为数据从内存载入CPU以后会一直有效。但是,当其他核/CPU去操作这块内存的时候,载入的数据就会失效,当前的核就需要重新去内存中载入这块内存。

如果有多个核同时在操作某一个内存的时候,这将会是灾难性的。因为每一次写,其他所有用到这个数据的核都需要重新载入这块内存。而多个核频繁写的话,就会让用到数据的和核频繁去载入内存。而我们知道从内存读数据是很慢的。这个现象可以很形象的称为cache-pingpong。

False Sharing

CPU载入内存的时候是按照一个内存块载入的,而不是具体单个内存地址,这块内存又称为cache-line。这个机制会带来什么问题呢?如果我们需要的是一个小块数据,但是cache-line在如何很多我们不需要的数据。我们不需要不要紧,麻烦的是这些数据可能被其他线程用到而且需要修改,当他们改了这块内存以后我们就莫名的躺枪了。因为我们载入的这块cache-line失效了。

Data Proximity

如果你需要处理的数据不再同一个cache-line里面也会带来问题,就是你需要载入很多内存块去用到你的数据。

设计数据结构的时候需要注意什么

针对上面这几个可能会影响性能的地方,我们再设计数据结构的时候需要注意一下几点:

  1. 给不同线程分配数据的时候要考虑他们之间的数据不要靠的太近避免false sharing。
  2. 单个线程需要的数据尽量挨得近一点,从而不用去载入太多cache-line。
  3. 尽量减少线程对于数据的需求。

设计多线程代码其他注意事项

异常安全

如果你起的线程有未处理的异常,程序将会退出(C++调用std::terminate)。当然你可以在线程代码内部处理掉异常,但是你需要将这个异常告诉外部从而外部能够知道错误状态来进行一些善后。

C++11里面可以用std::future的方式自然地实现这个,因为在std::future::get的时候如果线程有未处理的异常,会在调用的地方重新抛出来。

可扩展性

可扩展性的意思是当处理器数量增加的时候,程序能够在更短的时间内完成任务或者在相同时间内能够处理更多的数据。

一个程序内部可以分为两部分,一部分为串行部分,而另一部分则是可以被并行的部分。有一个Amdahl定律简单地描述了并发代码的性能,给出的公式如下:

$$ P = \dfrac{1}{f_s+\frac{1-f_s}{N}} $$

P为整体性能,$f_s$表示程序中串行的部分,N为处理器的数量。所以当试图优化代码的时候最好权衡一下优化能够带来的效益。应该让代码尽量的能够并行,这样能够充分利用好处理器增加带来的好处。另外如果串行的部分比例很大,比如说80%,那么优先去优化串行部分显然是比较划算的。

提高响应速度

在GUI应用中尽量将一些好使的操作放在非UI线程去做,这样子能够提高UI的响应速度从而提高用户体验。

本书第五章讲的是C++内存模型和原子操作。如果没有了解过内存模型,可能会在读这一章的时候云里雾里,不知道内存模型到底是干什么的。可以先通过这篇文章科普一下什么是内存模型)。简单的理解C++内存模型就是,C++规定的跟程序员之间的一个协议,协议内容主要是保证在data race的情况下程序的行为是什么样的。

C++内存模型

在谈具体的C++内存模型之前,需要先了解一下什么是重排(reordering),以及重排会给我们带来什么样的问题。

重排主要存在于两个阶段,一个阶段是编译时,当C++代码编译成指令的时候编译器会在不影响程序的前提下对程序进行重排和优化。另一个是运行时,处理器会在对结果没有影响的情况下对指令进行重排。两个重排的目的都是为了提升性能。这两个重拍的前提都是它们认为对结果不会产生影响,这个推导在单线程的情况下是成立的。但是当这段代码涉及到多线程,情况可能就会不如你所愿。举个很简单的例子, y==42乍看之下不可能为false,但是这个是不能够100%保证的。为什么?因为重排。而重排按照以下这种写法是无法控制的。

1
2
3
4
5
6
7
8
9
10
11
12
int x = 0;
int y = 0;
// run at thread 1
void M1() {
y = 42; // 1
x = 1; // 2
}
// run at thread 2
void M2() {
while (x!=1); // 1
assert(y+x==42); // 2
}

这也就是内存模型所要解决最大的问题:对抗重排。更加具体一点,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
2
3
4
5
6
7
8
9
10
11
12
// run at thread 1
void M1() {
y = 42; // 1
CAN_UP_NO_DOWN
x = 1; // 2
}
// run at thread 2
void M2() {
while (x!=1); // 1
NO_UP_CAN_DOWN
assert(y+x==43); // 2
}

那么对应到C++中的内存模型的话可以用下面这幅图来理解

explain_memory_order_using_fence

用这种通俗的理解方法,这四种内存模型(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-BeforeSynchronizes-With。推荐大家直接阅读参考资料中的两篇文章,讲得很好,以我目前的理解水平还不能够比这两篇文章更好更通俗的去解释这两个概念。读完就基本知道是怎么回事了。

C++ atomic注意事项

使用C++中的atomic有以下一些注意事项:

  • 预定义的atomic类型基本都重载了常用的操作符,所以你如果用起来的话可以跟普通基础类型一样。但是默认用的memory order是最强的memory_order_seq_cst。所以你在理解memory oder的情况下应该尽可能的使用相对较弱的memory order。特别是如果你的代码可能跑在ARM等弱内存序的处理器上。
  • 如果要使用一个自定义的类型扩展std::atomic ,那么这个类型必须满足一下两个条件:这个类型及其所有父类和非静态的成员都必须有trival copy-assignment操作符,简单地说就是能够用memcpy直接进行拷贝;二,必须是在字节层面能够直接比较的,可以理解为能够用memcmp进行比较

参考

前面介绍了线程间如何安全的共享数据,主要是通过互斥量和锁来实现。除了共享数据以外,线程间还需要进行同步的操作,比如说有某个事件需要通知。C++11引入了两个基本的机制来实现线程间的通信:condition variablesfutures

condition variables

condition variables主要是用来等待某个事件的通知,比如说线程1专门负责处理数据,线程2则是从IO读取数据放到队列里。这个时候线程1就需要等队列空闲的时候等待新的数据,而线程2则需要在数据放入队列以后通知线程1。代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// Thread 1

std::mutex mtx;
std::queue<data> data_queue;
std::condition_variable cond;

void process_data()
{
while (true)
{
std::unique_lock<mtx> lk;
cond.wait(lk, []{return !data_queue.empty();}); // 1
auto data = data_queue.front();
data_queue.pop();
lk.unlock(); // 2
process(data);
if (is_last_chunk(data))
break;
}
}

void prepare_data()
{
while(more_data_to_prepare())
{
auto data = prepare_data();
std::unique_lock<mtx> lk;
data_queue.push(data);
cond.notify_one(); // 3
}
}

这段代码有几个关键的知识点:

  • 为什么#1处condition_variable wait的时候需要传一个unique_lock
  • 为什么#1处,需要判断data_queue.empty
  • #1,除了wait以外还有没有其他方式?
  • 为什么#2处要显式unlock
  • #3 处是否还有其他notify的方式,并且有何区别?

为什么#1处condition_variable wait的时候需要传一个unique_lock

第一次看到condition_variable会比较奇怪为什么需要传入一个锁。这个是因为,condition_variable需要运行pred来检查是否满足等待条件,而这个往往是需要访问共享数据的,这个锁是用来保护这段共享数据的。

这个是其中一个原因,但是你会发现有一个wait函数是不需要传入predicate的。这个时候这个lock的意义则在于保护condition_variable本身内部的信号。因为多个线程会同时访问这个condition_variable,所以需要某种机制来对其内部的数据结构进行保护。[1]

为什么#1处,需要判断data_queue.empty

有一种wake up叫做spurious wakeup。简单地说就是,被notify了但是其实有可能它需要等待的那个条件已经不满足了,在这个例子中就是data_queue可能在它被唤醒的时候是empty。那么为什么会出现这种情况呢?

我们可以想一下condition_variable内部是怎么实现的,下面这段伪代码

1
2
3
4
5
6
7
void
condition_variable::wait(unique_lock<mutex> &lock)
{
lock.unlock();
// some system call to wait for event
lock.lock();
}

可以看到,wait的开始的时候应该会先把把mutex给释放掉,然后等待系统事件。当收到事件以后则会再次尝试去获取mutex。可以看到在收到系统事件和获取mutex这期间是没有任何mutex做为保护的。在这个时候很有可能外部的条件已经被另外的线程给修改掉了,所以之后再次获取到mutex并不能保证一定满足所需要等待的条件。

看了一下libcxx内部的实现,它其实是直接调用了pthread的pthread_cond_wait。而在pthread_cond_wait的内部实现则不是我上面说的那么简单,还有很多其他的操作。这个具体代码我也没有完全看明白,就不在这里详述,有机会另外再研究。(我看的是glibc里面pthread的实现)。

除了wait以外还有没有其他方式?

除了wait以外还提供了wait_untilwait_for两个api,主要是用来避免等待太久。内部实现的话wait_until其实是转换成了wait_for,所以一个比较有意思的事情是如果你在等待期间去修改系统的时间的话,对于最终等待的时间是不会产生影响的,它内部开始的时候已经换算成时间差了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
std::condition_variable cv;
std::mutex mtx;
std::unique_lock<std::mutex> lk(mtx);
auto now = std::chrono::system_clock::now();
std::time_t now_c = std::chrono::system_clock::to_time_t(now);
std::cout << std::put_time(std::localtime(&now_c), "%T") << std::endl;
cv.wait_until(lk, now + std::chrono::seconds(10));
now = std::chrono::system_clock::now();
now_c = std::chrono::system_clock::to_time_t(now);
std::cout << std::put_time(std::localtime(&now_c), "%T") << std::endl;

// output:
20:57:19
20:52:27

为什么#2处要显式unlock

拿到数据以后,data_queue已经不需要保护,而且处理数据可能会比较耗时。没有必要在这个地方去一直拿着mutex。

#3 处是否还有其他notify的方式,并且有何区别?

可以选择notify_onenotify_all,顾名思义。两个的区别是唤醒的等待线程数量不一样。notify方法调用的时候是需要去获取mutex的。

std::future

一个异步方法如何返回一个结果,C/C++程序员的第一反应应该是用回调函数。回调函数确实可以解决这个问题,但是回调函数本身也有着诸多的问题。C++11中引入lambda和闭包,能够让这个问题有所缓解。但是使用回调的方法并非一个优雅的解决方法。

举个例子

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
void CalcSomething()
{
std::mutex mtx;
std::condition_variable cv;

int result = 0;
doSomethingAsync(11, [&result, &cv](int r){
result = r;
cv.notify_one();
});
// Do other things
std::unique_lock<std::mutex> lk(mtx);
cv.wait(lk);
// use result to do more calculation
}

看着就蛋疼。那么future就是可以帮我们解决这个问题。有了它我们可以这么写代码。

1
2
3
4
5
6
7
8
void CalcSomething()
{
auto f = doSomethingAsync(11);
// Do other things
f.wait();
int result = f.get();
// use result to do more calculation
}

确实可以大大简化我们原来的代码,其实隐约看到另外一种编程的方法。但是在很多时候还是不够用,比如说CalcSomething也是一个异步方法,我想让结果等待以后继续做事情但不想同步的等。那么我们可以起一个线程去执行上述代码,比如

1
2
3
4
5
6
7
8
9
10
11
void CalcSomethingUseThread()
{
std::thread t([]{
auto f = doSomethingAsync(11);
// Do other things
f.wait();
int result = f.get();
// use result to do more calculation
});
t.detach();
}

但是很显然随随便便起一个线程只为做这么简单的事情未免开销太大了,而且很多项目里是有类似线程池来统一管理线程的。目前的future还不能够很好地处理这种需求,要么用独立线程运行,要么通过注册回调来做。

不过Folly::Futurestd::future做了极大的扩充,提供了类似的解决方案。有兴趣的可以去了解一下。

实际运用中C++11中的std::future有点鸡肋的感觉,除非在C++20以后能够加入更加多的功能,否则并没有很大的吸引力去用这套机制。

参考

  1. Stackoverflow - Why do pthreads’ condition variable functions require a mutex?
  2. Jon Skeet - Does C# Monitor.Wait() suffer from spurious wakeups?

最近在看 《C++ Concurrency in Action》第五章C++内存模型的时候,对于其中提到的一些知识点有点理解困难。所以另外找了一些资料先了解一些什么是内存模型,先对与内存模型有一个整体的概念然后再深入去了解C++的内存模型。

什么是内存模型

In computing, a memory model describes the interactions of threads through memory and their shared use of the data.
Wikipedia

有点抽象,似乎还是没有很好地定义出内存模型是什么,下面这段是摘自A Primer on Memory Consistency and Cache Coherence(APMCCC)1第三章。

A memory consistency model, or, more simply, a memory model, is a specification of the allowed behavior of multithreaded programs executing with shared memory. For a multithreaded program executing with specific input data, it specifies what values dynamic loads may return and what the final state of memory is.

简单的说,内存模型可以理解为一套协议,定义多个处理器/线程在在操作内存的时候需要遵循的一套协议。什么是允许的什么是不允许的。

可以看到这里的内存模型和内存一致性模型指的是同一个概念。

内存一致性模型的很多概念可以推广到一致性模型,一致性模型则是在分布式系统中很重要的一个概念。从某一个角度来说,多(核)处理器与内存的关系也是一种分布式系统。

内存模型的分类

内存模型可以分为硬件内存模型(hardware memory model)和软件内存模型(software memory model) 3

硬件内存模型可以理解为硬件与软件之间的一套协议(contract between the hardware and software) 2,硬件会在协议中说明会在什么情况下进行重排什么情况下不会。这里的硬件一般指的是处理器,而软件指的是操作处理器的指令。

软件内存模型可以理解为程序员和编程语言之间的一套协议(contract between programmer and programming language),编程语言会规定一套规则来说明什么情况下会对代码进行重排,并且提供一些机制能够让程序员对一些操作进行控制。

从硬件和软件的角度是看内存模型的一个维度,另一个维度则是一致性的强度。一致性可以是强一致性也可以是弱一致性。

Category

这张不严谨的图参考自Weak vs. Strong Memory Models,稍微做了一下变换。

大部分程序员关心的是软件维度的内存模型,即程序员和编程语言之间的协议。内存模型的强度与软硬件无管,是抽象层面的一个东西即适用于硬件也适用于软件。

wikipedia上我们可以看到有很多不同的一致性模型分类,这里对于C++中的内存模型比较相关的是:Sequential Consistency、Release consistency。

Sequential Consistency

Sequential Consistency(SC)是最符合直觉的一种内存模型也是很关键的一种。是由Leslie Lamport在1979年提出来的

理解这个模型有两个关键点:

  1. 程序的执行顺序按照代码顺序
  2. 代码中对于内存的访问不会被乱序

#1 很好理解,即执行顺序不会随意重排,尊重代码顺序。#2则不是很好实现,

Memory access as switch

上图为APMCCC中的截图。把内存操作想象成都需要通过一个开关,每次内存都挑选一个核允许其进行内存访问。这样就可以保证所有的内存访问不会被乱序。不难发现这个模型最大的问题就是同一时间只能有一个核对内存进行访问,这个很大程度上削弱了多核带来的提升。

Release consistency

在Release consistency中进入critical section(关键区)称为acquire,离开critical section称为release。Acquire操作是读操作,release是写操作。

简单的理解就是acquire操作会阻止所有在此操作以下的内存读取操作被重排到此操作前。release会保证所有操作之前的内存读取操作不会被重排到release操作后。下图可以很清晰的表达这个关系(The Purpose of memory_order_consume in C++11):

Release Consistency

总结

对于理解C++的内存模型,上面的这些预备知识就差不多足够了。强烈推荐阅读Preshing关于Lock-free的一系列博客,还有这本小书A Primer on Memory Consistency and Cache Coherence

Preshing的Lock-free的系列博客,没有列全,可以顺藤摸瓜找到相关文章。

参考

  1. A Primer on Memory Consistency and Cache Coherence, Daniel J. Sorin, Mark D. Hill, David A. Wood
  2. Memory Consistency Models: A Primer, James Bornholt
  3. Weak vs. Strong Memory Models, Jeff Preshing
  4. Acquire and Release Semantics
  5. “Strong” and “weak” hardware memory models