分类: C/C++
2008-05-31 09:17:58
另一方面,一个内存模型对于语言的使用方回答这样一个问题:
需要遵循哪些规则,才能使程序是正确的。(废话,这里的“正确”当然是多线程意义下的正确了。)
了解了这个定义之后我们便可以对号入座来拷问目前的C++03标准为什么在多线程上出了问题了。 [Page]
为什么C++03标准不能保证多线程正确性
我们来看一个简单的例子:
int count = 0;
bool flag = false;
Thread1 Thread2
count = 1; while(!flag);
flag = true; r0 = count;
按照我们的直觉,r0不可能为读到count为0,因为等到while(!flag)执行完毕的时候,flag必定已经被赋为true,也就是说count=1必定已经发生了。
然而,实际上,在C++03下,r0读到count为0的可能性是存在的,因为count=1和flag=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 Boehm的paper上的例子更简单一点:
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是单线程的,而互换这两个操作对单线程的语意完全没有任何影响(对于一根筋通到底的编译器来说,它们眼里看到的是x、y这两个无关的变量)。
为什么volatile是个废物
那么,你可能会问,volatile可不可以用在这里,从而得到想要的结果呢?很遗憾,答案是否定的。对volatile的这个误解从来就没有停止过,去comp.lang.c++.moderated新闻组上搜一搜就会发现了,我怀疑在C++所有的语言特性所引发的口水中那些由volatile引发的至少要占到30%。volatile当之无愧为C/C++里面最晦涩的语言特性之一。
为什么volatile不可以用在这里,Scott Meyers和Andrei Alexandrescu作了一个极其漂亮的阐述,ridiculous fish同学也写了一个漂亮的post。不过瘾的话这里还有一份由Java大牛们集体签名的申明。
总而言之,由于C++03标准是单线程的,因此volatile只能保证单线程内语意。对于前面的那个例子,将x和y设为volatile只能保证分别在Thread1和Thread2中的两个操作是按代码顺序执行的,但并不能保证在Thread2“眼里”的Thread1的两个操作是按代码顺序执行的。也就是说,只能保证两个操作的线程内次序,不能保证它们的线程间次序。 [Page]
一句话,目前的volatile语意是无法保证多线程下的操作的正确性的。
为什么多线程库也(基本)是废物
那么,同样又会有人问了:那么库呢,可不可以通过多线程库来编写出正确的多线程程序呢?这就是Hans Boehm那个paper所要论述的内容了。该paper全长仅仅8页,核心内容也不过两三页纸。但由于涉及了对标准中最晦涩的内容如何进行解释,所以非常难读。其实它的中心思想可以用一句简单的话概括出来:
因为C++03标准是单线程的,所以即便是完全符合标准的编译器也可能各个脑袋里面只装着一个线程,于是在对代码作优化的时候总是一不小心就可能做出危害多线程正确性的优化来。
Hans Boehm在paper里面举了三个例子,每个例子都代表一类情况。
第一个例子:
x = y = 0;
Thread1 Thread2
if(x == 1) ++y; if(y == 1) ++x;
以上代码中存在data-race吗?由于x和y一开始都是1,所以答案是:不存在,因为两个if条件都不会满足,从而对x和y的++操作根本就不会被执行。但,真正的问题是,在现行标准下,编译器完全可以作出如下的优化:
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;
[NextPage]
那么如下的操作
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\\0’ | x.a;
如果原先的代码中有另一个线程在对x.a进行写操作,data-race就不幸发生了。而且还是违反直觉的发生的——程序员泪眼汪汪的问:我操作x.b~x.h关x.a什么事呢?
问题就在于,现行标准没说清什么是一个“内存位置(memory location)”。于是N2171提案里面是这样写的:
A memory location is either an object of scalar type, or a maximal sequence of adjacent bit-fields all having non-zero width. Two threads of execution can update and access separate memory locations without interfering with each other. [Page]
一个内存位置要么是一个标量、要么是一组紧邻的具有非零长度的位域。两个不同的线程可以互不干扰地对不同的内存位置进行读写操作。
第三个例子:
for (...) {
...
if (mt) pthread_mutex_lock(...);
x = ... x ...
if (mt) pthread_mutex_unlock(...);
}
对于以上代码,貌似是不会有data-race了,因为x的访问已经被pthread_mutex_(un)lock()包围(保卫?)起来了。但果真如此吗?
“聪明”的编译器只要运用一种“成熟”的叫做register promotion的技术就可以破坏这段表面平静的代码:
r = x;
for (...) {
...
if (mt) {
x = r; pthread_mutex_lock(...); r = x;
}
r = ... r ...
if (mt) {
x = r; pthread_mutex_unlock(...); r = x;
}
}
x = r;
在单线程上下文中,以上优化是完全合法的,而且也的确能够带来效率提升。但由于它将原本只能位于临界区内部的x的写操作“提升”到了临界区外面。结果到了多线程环境下就挂了。对此POSIX线程库也无能为力。
那么,究竟如何才能允许用户编写正确的多线程代码呢?
一个简单的办法就是禁止编译器作任何优化:所有的操作严格按照代码顺序执行,所有的操作都触发cache coherence操作以确保它们的副作用在跨线程间的visibility顺序。但这样做显然是不实际的——多线程本来的目的就是为了提升效率,这下倒好,为何实现多线程正确性却要付出巨大的效率代价了。但为什么要考虑这个方案呢,目的就是要明确我们的目的是什么:我们的目的是,使代码能够“看起来像是”被“顺序一致性(sequential consistency)地”执行的。所谓顺序一致性其实没什么神秘的,我们一开始被教导的多线程程序被执行的方式就是所谓的顺序一致性的:即多个线程的所有操作被穿插交错执行,但各个线程内的各操作之间的相对顺序被遵守——别紧张,就是你脑袋里那个对于多线程如何被执行的概念。
那么,要想实现顺序一致性,难道除了禁止一切优化就没有其它办法了吗?我们注意到,实际上在一个线程内部,几乎绝大多数的操作都是单线程的,也就是说,它们操作的都是局部变量,或者进一步说,对其它线程不可见的变量。也就是说,绝大多数的操作对其它线程来说都是不可见的。对于这部分操作,编译器完全可以自由地按照单线程语意来进行优化,动用所有古老的单线程环境下的优化技术都没问题。最关键的就是那部分“线程间可见”的操作。对于这部分操作,编译器必须确保它们的“对外形象”。
那么,编译器能否分辨出哪些操作是单线程的,哪些操作是多线程的呢?很大程度上,这是可以的。所有对局部变量的操作,都是单线程可见的。所有对全局变量的操作都是多线程可见的。因此是不是可以说,编译器只要对那些全局变量操作小心一点就可以了呢?答案是还不够。因为这样的编译模型要求编译器对所有针对全局变量的操作都禁用任何优化,并且还要时不时通过插入memory fences(或称memory barrier)来确保cache coherence。这个代价,还是太大。
于是所谓的data-race-free模型粉墨登场。Data-race-free模型的核心内容是: [Page]
只要你通过基本的同步原语(由标准库提供,如Lock)来保证你的程序是没有data-race的,那么编译器就能向你保证你的程序是被sequentially-consistent地执行的。
为什么这个模型是有优势的。是因为它最大化了编译器可能作的优化。举个简单的例子:
… // #1
Lock(m)
… // #2
Unlock(m)
… // #3
在这样的一段程序中,#1、#2、#3处的代码完全可以享受所有的单线程优化。编译器再也不用去猜测哪些操作是有线程间语意的哪些操作没有了,省心省事。在以上的程序中,Lock(m)和Unlock(m)就充当了所谓的one-way barrier(单向内存栅栏),不同的是Lock(m)具有Acquire语意,而Unlock(m)具有Release语意。Acquire语意是说所有下方的操作都不能往其上方移动,Release语意则相反。对于上面的代码来说也就是说,编译器的优化不能将#2处的代码移到临界区外,但可以将#1、#3处的代码往临界区内移。
至于为什么data-race-free能够确保sequential-consistency,我以前写过一篇文章阐述这一点,其实也就是阐述这个经典的证明。不过由于是用英文写的,所以读的人很少。因为当时没有作任何的铺垫,所以读懂的就更少了。