《C++0x漫谈》系列之:多线程内存模型

C++0x漫谈》系列之:多线程内存模型

By 刘未鹏(pongba)

刘言|C++的罗浮宫(http://blog.csdn.net/pongba)

C++0x漫谈》系列导言

这个系列其实早就想写了,断断续续关注C++0x也大约有两年余了,其间看着各个重要proposals一路review过来:rvalue-referencesconceptsmemory-modelvariadic-templatestemplate-aliasesauto/decltypeGCinitializer-lists…

总的来说C++09C++98相比的变化是极其重大的。这个变化体现在三个方面,一个是形式上的变化,即在编码形式层面的支持,也就是对应我们所谓的编程范式(paradigm)C++09不会引入新的编程范式,但在对泛型编程(GP)这个范式的支持上会得到质的提高:conceptsvariadic-templatesauto/decltypetemplate-aliasesinitializer-lists皆属于这类特性。另一个是内在的变化,即并非代码组织表达方面的,memory-modelGC属于这一类。最后一个是既有形式又有内在的,r-value references属于这类。

这个系列如果能够写下去,会陆续将C++09的新特性介绍出来。鉴于已经有许多牛人写了很多很好的tutor这里这里,还有C++标准主页上的一些introductiveproposals,如这里,此外C++社群中老当益壮的Lawrence Crowl也在google做了非常漂亮的talk)。所以我就不作重复劳动了:),我会尽量从一个宏观的层面,如特性引入的动机,特性引入过程中经历的修改,特性本身的最具代表性的使用场景,特性对编程范式的影响等方面进行介绍。至于细节,大家可以见每篇介绍末尾的延伸阅读。

多线程内存模型

动机

内存模型是C++09最重大的特性之一,之所以重大是因为多线程并发编程将成为下一个十年的主题之一,对此C++小胡子Herb Sutter早有精彩的论述

为什么在C++里面要想顺畅地进行多线程编程需要对标准进行修订(而不仅仅是通过现有的多线程库如POSIXboost.Thread即可)呢?对此Hans Boehm在他的著名的超级晦涩难懂的paper——Threads Cannot be Implemented as a Library——里面其实已经详尽地阐述了原因,但是,一,尽管这篇paper被到处citenewsgroup上面关于到底能不能用volatile来实现线程安全性这类问题还是争议不断。这方面就连C++牛魔王Andrei Alexandrescu都犯过错误,可见有多难缠。二,这篇paper很难读,一般人就算头悬梁锥刺股一口气读上N遍,一转眼的工夫就又成丈二和尚了。Memory-model与多线程是一个非常棘手的领域。记得Andrei Alexandrescu曾在一篇专栏文章里面提到,大意是说,泛型编程难、编写异常安全的代码更难,但跟多线程编程比起来,它们就都成了娃娃吃奶。

因此,要想比较透彻理解C++09内存模型的动机,光是Hans的那篇paper是不够的,C++09的内存模型沿袭的是Java的内存模型,Java社群在这个上面花了玩命的工夫,最后修订出来的标准的复杂度达到了不是给人看的地步(当然,只是其中的一个小部分,并非全部),Jeremy Mansongoogle做了一个关于java memory modeltalk,也只是浅浅的从宏观层面谈了一下。所以既然Java社群已经花了这个工夫,而且C/C++/Java本就是同根生,所以也就乐得发扬一下拿来主义了,订阅了相关mailing-list的老大们会发现Java社群这方面的几个老大也时常在里面发言,语言无疆界啊:)

用一句话来说,修订C++的内存模型的原因在于:

现有的内存模型无法保证我们写出可移植的多线程程序

那为什么无法保证呢。对此许多人都用一句模棱两可令人摸不着头脑的话来解释:因为C++98中的内存模型是单线程的(虽然标准没有明确指出,但这是一个隐含结论)。这句话说了等于没说,让我想起那个关于数学家的笑话。人们难免要问,那为什么内存模型是单线程的就意味着无法写出可移植的多线程程序来呢?POSIX线程模型指导下不是存在了那么多的C/C++多线程程序吗?这又怎么解释呢?

所以,必得有一个最本质的解释,下面这个就是:

现有的单线程内存模型没有对编译器做足够的限制,从而许多我们看上去应该是安全的多线程程序,由于编译器不知道(并且根据现行标准(C++03)的单线程模型,编译器也无需关心)多线程的存在,从而可能做出不违反标准,但能够破坏程序正确性的优化(这类优化一旦导致错误便极难调试,基本属于非查看生成的汇编代码不可的那种)。

OK,说到现在,对什么是“内存模型”还没有解释呢。内存模型的正式定义很是飘逸,jsr133中这么说:

A memory model describes, given a program and an execution trace of that program, whether the execution trace is a legal execution of the program.

内存模型描述给定程序的某个特定的执行轨迹是否是该程序的一个合法执行。

其实这句话正常人多读几遍多少还能有有点似乎理解的感觉。接下来一句话就更诡异了:

For the Java programming language, the memory model works by examining each read in an execution trace and checking that the write observed by that read is valid according to certain rules.

对于Java来说,内存模型的工作模式如下:对一个给定执行轨迹上的每一读取操作(read),检查该读取操作所读到的对应的写操作结果(write)是否不违背一定的规则。

以上是内存模型的技术定义,其最大的缺点就是不能帮我们感性而直观的理解什么是内存模型。

目前的多线程编程模型从广义上来说一般不外乎共享内存模型(多个线程访问共享空间,通过加锁解锁和对全局变量的操作来进行交互)和消息传递模型这两种。其中共享内存模型目前仍然是主流中的主流(比如C家族语言用的就都是这一招),消息传递模型大家也都不陌生,目前最成熟的应用是在Erlang里面。当然,这样的分类是往大了说,往小了说可就麻烦了,可以参考这里

本文要说的内存模型是针对共享内存下的多线程并发编程的。内存模型的技术定义刚才已经饶舌过了,其非技术定义是这样的:

一个内存模型对于语言的实现方回答这样一个问题:

哪些优化是被允许的(这里的“优化”其实僵硬地说应该是transformations,当然,由于这是我杜撰的非官方定义,所以就管不了那么多繁文缛节了。)

另一方面,一个内存模型对于语言的使用方回答这样一个问题:

需要遵循哪些规则,才能使程序是正确的。(废话,这里的“正确”当然是多线程意义下的正确了。)

了解了这个定义之后我们便可以对号入座来拷问目前的C++03标准为什么在多线程上出了问题了。

为什么C++03标准不能保证多线程正确性

我们来看一个简单的例子:

int count = 0;

bool flag = false;

Thread1 Thread2

count = 1; while(!flag);

flag = true; r0 = count;

按照我们的直觉,r0不可能为读到count0,因为等到while(!flag)执行完毕的时候,flag必定已经被赋为true,也就是说count=1必定已经发生了。

然而,实际上,在C++03下,r0读到count0的可能性是存在的,因为count=1flag=true的次序可以被颠倒。为什么可以被颠倒,是因为颠倒不影响所谓的Observable Behavior

Observable Behavior: 标准把Observable Behavior(可观察行为)定义为volatile变量的读写和I/O操作。原因也很简单,因为它们是Observable的。volatile变量可能对应于memory mapped I/O,所有I/O操作在外界都有可观察的效应,而所有内存内的操作都是不显山露水的,举个简单的例子:

int main()

{

int sum;

for(int i = 0; i < n ; ++i) sum += arr[i];

printf(“%d”, sum);

}

如果编译器知道arr里面各项的值(如果arr事先被静态初始化了的话),那么那个for循环就可以完全优化掉,直接输出arr各项和即可。为什么这个for循环可以优化掉?因为它不具备Observable Behavior

有点迷糊?Hans Boehmpaper上的例子更简单一点:

x = y = 0;

Thread1 Thread2

x = 1; y = 1;

r1 = y; r2 = x;

很显然,结果要么r1==1要么r2==1。不可能出现r1==r2==0的情况,因为如果r1读到y值是0,那么表明r1=y先于y=1发生,从而先于r2=x发生,又由于x=1先于r1=y发生,因而x=1先于r2=x发生,于是r2就会读到1

当然,以上这段分析是理论上的。地球人都知道,理论上,理论跟实际是没有差别的,但实际上,理论跟实际的差别是相当大滴。对于本例来说,事实上r1==r2==0的情况是完全可能发生的。只需把Thread1里面的两个操作互换一下即可。为什么可以互换呢?因为这样做并不违反标准,C++03是单线程的,而互换这两个操作对单线程的语意完全没有任何影响(对于一根筋通到底的编译器来说,它们眼里看到的是xy这两个无关的变量)。

为什么volatile是个废物

那么,你可能会问,volatile可不可以用在这里,从而得到想要的结果呢?很遗憾,答案是否定的。对volatile的这个误解从来就没有停止过,去comp.lang.c++.moderated新闻组上搜一搜就会发现了,我怀疑在C++所有的语言特性所引发的口水中那些由volatile引发的至少要占到30%volatile当之无愧为C/C++里面最晦涩的语言特性之一。

为什么volatile不可以用在这里,Scott MeyersAndrei Alexandrescu作了一个极其漂亮的阐述ridiculous fish同学也写了一个漂亮的post。不过瘾的话这里还有一份由Java大牛们集体签名的申明

总而言之,由于C++03标准是单线程的,因此volatile只能保证单线程内语意。对于前面的那个例子,将xy设为volatile只能保证分别在Thread1Thread2中的两个操作是按代码顺序执行的,但并不能保证在Thread2“眼里”的Thread1的两个操作是按代码顺序执行的。也就是说,只能保证两个操作的线程内次序,不能保证它们的线程间次序。

一句话,目前的volatile语意是无法保证多线程下的操作的正确性的。

为什么多线程库也(基本)是废物

那么,同样又会有人问了:那么库呢,可不可以通过多线程库来编写出正确的多线程程序呢?这就是Hans Boehm那个paper所要论述的内容了。该paper全长仅仅8页,核心内容也不过两三页纸。但由于涉及了对标准中最晦涩的内容如何进行解释,所以非常难读。其实它的中心思想可以用一句简单的话概括出来:

因为C++03标准是单线程的,所以即便是完全符合标准的编译器也可能各个脑袋里面只装着一个线程,于是在对代码作优化的时候总是一不小心就可能做出危害多线程正确性的优化来。

Hans Boehmpaper里面举了三个例子,每个例子都代表一类情况。

第一个例子:

x = y = 0;

Thread1 Thread2

if(x == 1) ++y; if(y == 1) ++x;

以上代码中存在data-race吗?由于xy一开始都是0,所以答案是:不存在,因为两个if条件都不会满足,从而对xy++操作根本就不会被执行。但,真正的问题是,在现行标准下,编译器完全可以作出如下的优化:

x = y = 0;

Thread1 Thread2

++y; ++x;

if(x != 1) --y; if(y != 1) --x;

于是data-race大摇大摆地出现了。你能说这是编译器的错吗?人家可是遵章守纪的好市民。以上的代码转换并没有违背任何单线程内的语意。所以,唯一的错误是在标准本身身上。标准只要说一句:在这种情况下,所有的sequential consistent的执行路径都不可能导致data-race,因此,该程序内不存在data-race。就万事大吉了。

第二个例子:

struct

{

int a : 17;

int b : 15;

} x;

一个线程写x.a,另一个线程写x.b。有data-race吗?目前的标准对此一言不发。我们最妥善的做法也只能是在无论读取x.a或是x.b的时候将整个x哐当用锁锁起来。

第二一撇个例子:

struct { char a; char b; char c; char d;

char e; char f; char g; char h; } x;

那么如下的操作

x.b = ’b’; x.c = ’c’; x.d = ’d’;

x.e = ’e’; x.f = ’f’; x.g = ’g’; x.h = ’h’;

会涉及到x.a吗?答案是会,因为编译器只要这么转换一下:

x = ’hgfedcb\<chmetcnv w:st="on" unitname="’" sourcevalue="0" hasspace="False" negative="False" numbertype="1" tcsc="0">0’</chmetcnv> | x.a;

如果原先的代码中有另一个线程在对x.a进行写操作,data-race就不幸发生了。而且还是违反直觉的发生的——程序员泪眼汪汪的问:我操作x.b~x.hx.a什么事呢?

M

你可能感兴趣的:(多线程,C++,c,C#,领域模型)