http://www.bccn.net/Article/kfyy/vc/jszl/200709/6188_2.html
泛型<编程>:volatile——多线程程序员最好的朋友volatile修正符及让你的编译器为你检查竞态条件
我不想破坏你的情绪,但这篇专栏针对多线程编程中最可怕的问题。如果说——正如前面一篇泛型<编程>所说的——写出意外安全
(exception-safe)的程序很难,但写意外安全的程序和多线程编程比起来就是小孩子的玩意。
用到多线程的程序是众所周知地难写,难验证,难调试,难维护,总的来说难以驾御。不正确的多线程程序可能会运行几年都不出问题,但在某些时
间条件符合时就会导致不可预料的灾难。
不用说,一个写多线程代码的程序员需要一切能得到的帮助。这篇专栏集中讨论竞态条件——在多线程程序中普遍的问题来源——让你了解如何避免
它并提供给你工具,而且会让你惊喜地看到你能够让编译器积极地帮助你处理这个问题。
只是个小小的关键字
尽管C和C++标准都明显地对线程保持沉默,它们还是对多线程做了小小的让步,这种让步表现为volatile关键字。
正如它的更为人所知的伙伴const, volatile是个类型修正符(type modifier)。它的作用是和变量连用使变量能被不同线程访问和修改。
根本上说,如果没有volatile的话,要么不可能写出多线程程序,要么编译器浪费极大的优化机会。现在来解释为什么会是这种情况。
考虑下面代码:
class Gadget
{
public:
void Wait ()
{
while (!flag_)
{
Sleep(1000); //睡眠1000毫秒
}
}
void Wakeup ()
{
flag_ = true;
}
...
private:
bool flag_;
};
上面Gadget::Wait的作用是每秒检查一次flag_成员变量,如果那个变量被其他线程设为true时返回。至少这是程序员的本来意图,但,唉,Wait函数
是错误的。
如果编译器断定Sleep(1000)是对外部库的一个调用,而且这个调用不可能修改成员变量flag_。那么编译器会决定在寄存器中缓存flag_并且用那个寄
存器替代较慢的内存。这对单线程代码来说是非常好的优化,但在现在这个情况下,这个优化破坏了正确性:你对某个Gadget对象调用Wait后,尽管
另一个线程调用了Wakeup,Wait还会永远循环下去。这是因为对flag_的修改不会反映到缓存flag_的寄存器。这个优化实在是......过度优化了。
把变量缓存到寄存器中在大多数时候是一项非常有用的优化,浪费掉就太可惜了。C和C++给你机会来显式禁用这个优化。如果你用volatile标识一个
变量,编译器就不会把那个变量缓存到积存器中——对变量的每次访问都直接通过实际内存的位置。所以要让Gadget的Wait/Wakeup正常工作只要
正确修饰flag_
class Gadget
{
public:
...同上...
private:
volatile bool flag_;
};
大多数对volatile用途和用法的解释到此为止,并且建议你在多线程中对基本类型加volatile标识符。但是,用volatile你可以做更多事情,因为它是
C++奇妙的类型系统的一部分。
对用户定义类型使用volatile
你不单单能够在基本类型前加volatile标识符,而且也能在用户定义类型前加。在这种情况下。volatile象const一样修改这个类型(你也能够同时对
同一个类型加const和volatile)
但是不象const,volatile对基本类型和用户定义类型作用不同。就是说,不象类,基本类型加了volatile标识符后仍旧支持它们所有的操作(加,乘
,赋值,等等。)。比如,你能够把一个非volatile int赋给一个volatile int,但你不能把一个非volatile对象赋给一个volatile对象。
我们来举例说明volatile怎样作用于用户定义类型。
Class Gadge
{
public:
void Foo() volatile;
void Bar();
...
private:
String name_;
Int state_;
};
...
Gadget regularGadget;
Volatile Gadget volatileGadget;
如果你认为volatile对对象不起作用,那准备好被吓一跳吧。
volatileGadget.Foo(); //成功,对volatile对象调用volatile函数没有问题
regularGadget.Foo(); //成功,对非volatile对象调用volatile函数没有问题
volatileGadget.Bar(); //失败!不能对volatile对象调用非volatile函数
把无标识类型转换为对应的volatile对象很简单。然而,你不能把volatile转回无标识。你必须用cast:
Gadget& ref = const_cast(volatileGadget);
Ref.Bar(); //成功
一个有volatile标识符的类只能访问它接口的子集,一个由类的实现者来控制的子集。用户只能用const_cast来获得对类型接口的完全访问。此外,就
象const,volatile会从类传递到它的成员(比如,volatileGadget.name_和volatileGadget.state_是volatile变量)
volatile,临界区(Critical Sections),和竞态条件(Race Conditions)
多线程程序里最简单的也是用得最多得同步设施是mutex,一个mutext提供Acquire和Release基本功能。一旦你在某个线程中调用Acquire,任何其
他调用Acquire得线程会被堵塞。稍后当那个线程调用Release,正好会有一个先前被Acquire堵塞的线程被释放。换句话说,有了一个mutex,只有
一个线程可以在Acquire调用和Release调用之间得到处理器时间。在Acquire调用和Release调用之间的执行代码本身就是一个临界区。(Windows
术语有点让人迷惑,因为它把mutex本身叫做一个critical section(临界区)。尽管"mutext"实际上是一个进程范围内mutex,但把他们叫做线程
mutex和进程mutx会更好些)
mutex是用来保护数据,防范竞态条件的。根据定义,当多线程对数据处理的结果由线程如何被调度决定时,一个竟态条件产生。当二个或以上的线
程竞争使用同样数据时竞态条件出现。因为线程可能在任意时间点被中断,正被处理的数据可能被破坏或被误判。结果是,对数据的修改动作或者有
时候时读取动作必须用临界区仔细保护起来。在面向对象编程中,这通常意味着你在一个类里存放一个mutex作为成员变量,当你在存取类的数据时
使用它。
有经验的多线程程序员在阅读上面两段时可能已经在打哈欠了,但那两段的目的是提供一个热身,因为现在我们要把多线程编程和volatile联系起来了
。我们通过把C++的类型世界和线程语义世界的相交之处勾画出来来做到这一点。
* 临界区之外,任何线程可以在任意时刻被任意其他线程中断,其中不存在任何控制,,所以结果是被多个线程访问的变量为volatile。这也保持了
volatile的原来意图——防止编译器不小心缓存被多个线程立刻用到的值。
* 临界区之内定义有一个mutex,只有一个线程能够访问。结果是,在一个临界区内,执行代码有单线程环境的语义。使用的变量不能是
volatile——你能够去除volatile标识符。
简而言之在概念上,被多个线程共享的数据在临界区外是volatile,在临界区内是非volatile.
你通过锁一个mutex来进入临界区。你通过使用一个const_cast来去除volatile标识符。你如果把这两个操作放在一起,我们就在C++类型系统和应
用程序的线程语义之间建立了一个联系。我们就能够让编译器来为我们检查竞态条件。
LockingPtr
我们需要一个工具来集中一个mutex的获取操作和一个const_cast。我们来开发LockingPtr模板类,你能够用一个volatile对象obj和一个mutex对象
mtx来初始化这个模板类。在这个模板类的生存期内,一个LockingPtr保持mtx始终被占用。同时,LockingPtr对去除volatile的obj提供访问。这个
访问是用智能指针方式,通过operator->和operator*来提供。在LockingPtr内执行const_cast,这个转换语义上是有效的,因为LockingPtr在生存
期内保持mutex被占用。
首先我们来定义LockingPtr用到的Mutex类的骨架:
class Mutex
{
public:
void Acquire();
void Release();
...
};
为了能使用LockingPtr,你要用你操作系统用到的数据结构和基本函数来实现Mutex。
LockingPtr用受控的变量的类型来作为模板。举例来说,如果你想管理一个Widget,你使用一个LockingPtr,这样你可以用一个类型为volatile
Widget的变量来初始化它。
LockingPtr的定义非常简单。LockingPtr实现一个相对简单的smart pointer。它目的只是把一个const_cast和一个临界区集中在一起。
Template
Class LockingPtr {
Public:
//构造/析构函数
LockingPtr(volatile T& obj, Mutex& mtx)
: pObj_(const_cast(&obj)),
pMtx_(&mtx)
{ mtx.Lock(); }
~LockingPtr()
{ pMtx_->Unlock(); }
//模拟指针行为
T& operator*()
{ return *pObj_; }
T* operator->()
{ return pObj_; }
private:
T* pObj_;
Mutex* pMtx_;
LockingPtr(const LockingPtr&);
LockingPtr& operator=(const LockingPtr&);
};
尽管简单,LockingPtr对写出正确的多线程代码非常有帮助。你应该把被几个线程共享的对象定义为volatile而且不能对它们使用const_cast——应
该始终使用LockingPtr自动对象。我们通过一个例子来说明:
假设你有两个线程共享一个vector对象
class SyncBuf {
public:
void Thread1();
void Thread2();
private:
typedef vector BufT;
volatile BufT buffer_;
Mutex mtx_; //控制对buffer_的访问
};
在一个线程函数中,你简单地使用一个LockingPtr来取得对buffer_成员变量的受控访问:
void SyncBuf::Thread1() {
LockingPtr lpBuf(buffer_, mtx_);
BufT::iterator I = lpBuf->begin();
For (; I != lpBuf->end(); ++I) {
...使用*i...
}
}
这些代码既非常容易写也非常容易懂——任何时候你需要用到buffer_,你必须创建一个LockingPtr指向它。一旦你这样做,你就能够使用vecotr的所
有接口。
非常好的事情是,如果你犯了错,编译器会指出来:
void SyncBuf::Thread2() {
//错误,不能对一个volatile对象调用begin()
BufT::iterator I = buffer_.begin();
//错误!不能对一个volatile对象调用end()
for (; I != lpBuf->end(); ++I) {
...使用*i...
}
}
你不能调用buffer_的任何函数,除非你要么使用一个const_cast要么使用LockingPtr。区别是LockingPtr提供了一个有序的途径来对volatile变量使
用const_cast。
LockingPtr非常有表现力。如果你只需要调用一个函数,你能够创建一个无名临时LockingPtr对象并直接使用它:
Unsigned int SyncBuf::Size() {
Return LockingPtr(buffer_, mtx_)->size();
}
回到基本类型
我们已经看到了volatile保护对象不被不受控制地访问时是多么出色,也看到了LockingPtr提供了多么简单和高效的方法来写线程安全的代码。让我
们回到基本类型,那些加了volatile后行为与用户自定类型不同的类型
我们来考虑一个例子,多个线程共享一个类型为int的变量。
Class Count
{
public:
...
void Increment() { ++ctr_; }
void Decrement() { --ctr_; }
private:
int ctr_;
};
如果Increment和Decrement被不同线程调用,上面的代码片段是有问题的。首先,ctr_必须是volatile,其次,即使象++ctr_那样看上去是原子操
作的函数实际上是一个三步操作。内存本身没有算术能力,当递增一个变量时,处理器:
* 读取那个变量到寄存器
* 在寄存器中增加值
* 把结果写回内存
这个三步操作叫做RMW(Read-Modify-Write 读-改-写)。在执行一个RMW操作的“改”
操作时,为了让其他处理器访问内存,大多数处理器会释放内存总线。
如果那时另一个处理器对同一个变量执行一个RMW操作,我们就有了一个竞态条件;第二个写操作覆盖了第一个的结果。
你也能够用LockingPtr避免这种情况:
class Counter
{
public:
...
void Increment() { ++*LockingPtr(ctr_, mtx_); }
void Decrement() { --*LockingPtr(ctr_, mtx_); }
private:
volatile int ctr_;
Mutex mtx_;
};
现在代码正确了,但代码质量比较SyncBuf的代码而言差了很多。为什么?因为在Counter里,如果你错误地直接访问ctr_(没有先对它加锁)编译器不
会警告你。如果ctr_是volatile,++ctr_也能编译通过,但产生的代码明显是错误的。编译器不再是你的帮手了,只有靠你自己注意才能避免这样的竞
态条件。
那你应该怎么做?简单地把你用到的基本数据包装为更高层次的结构,对那些结构用volatile。荒谬的是,尽管本来volatile的用途是用在内建类型上
,但实际上直接这样做不是个好主意!
volatile成员函数
到目前为止,我们已经有了包含有volatile数据成员的类,现在我们来考虑设计作为更大对象一部分的类,这些类也被多线程共享。在这里用volatile
成员函数有很大帮助。
当设计你的类时,你只对那些线程安全的成员函数加voaltile标识。你必须假定外部代码会用任何代码在任何时刻调用volatile函数。不要忘记:
volatile等于可自由用于多线程代码而不用临界区,非volatile等于单线程环境或在一个临界区内。
例如,你定义一个Widget类,实现一个函数的两个变化——一个线程安全的和一个快的无保护的。
Class Widget
{
public:
void Operation() volatile;
void Operation();
...
private:
Mutex mtx_;
};
注意用了重载。现在Widget的用户可以用同样的语法来调用Operation,无论你为了获得线程安全调用volatile对象的Operation还是为了获得速度
调用常规对象的Operation。但用户必须小心地把被多线程共享的Widget对象定义为volatile。
当实现一个volatile成员函数时,第一个操作通常是对this用一个LockingPtr加锁。剩下的工作可以交给非volatile的对应函数:
void Widget::Operation() volatile
{
LockingPtr lpThis(*this, mtx_);
LpThis->Operation(); //调用非volatile函数
}
总结
当写多线程程序时,你可以用volatile得到好处。你必须遵守下面的规则:
* 定义所有的被共享的对象为volatile。
* 不要对基本类型直接用volatile
* 当定义可被共享类时,使用volatile成员函数来表示线程安全。
如果你这样做,而且如果你使用那个简单的返型组件LockingPtr,你能够写出线程安
全的代码而不用更多考虑竞态条件,因为编译器能为你留心,会为你主动指出你错误的地方。
我参与的几个使用volatile和LockingPtr的计划获得很好的效果。代码清晰易懂。我记得碰到几处死锁,但我情愿遇到死锁也不要竞态条件,因为死
锁调试起来容易得多。事实上没有遇到任何问题是关于竞态条件的。
补充:Volatile实际上被滥用了?
我收到对于我2月份专栏文章“返型<编程>:volatile——多线程程序员的好朋友”的许多反馈。正如往常,我收到的赞誉都来自私人信件,然而抱
怨都发在Usenet新闻组comp.lang.c++.moderated和comp.programming上。随后的争论激烈而漫长,如果你对这个主题感兴趣,你可以去看一
下。帖子名为“volatile,was memory visibility between threads"
我从那个帖子里也学到了很多。一件事情是,文中开头的Widget例子是错误的。为了长话短说,在有些系统(比如POSIX兼容系统)不需要volatile
标识符,另外一些系统加了volatile没有用,程序还是不正确。
最重要的问题是volatile是依赖于类似于POSIX的mutexes设施,有些多处理器系统用mutexes是不够的——你必须用内存屏障(memory barriers
)。
另一个更哲学化的问题是,严格说来,把volatile从变量前转换掉是非法的,即使是你自己为了volatile正确性增加了volatile标志。正如Anthony
Williams指出的,一个系统可能有足够理由会把volatile数据存放在不同于非volatile数据的地方,这样的地址转换行为错误。
还有另一个批评是volatile的正确性。尽管它在低层解决竞态条件,但无法正确检测更高层的,逻辑上的竞态条件。比如,你有个mt_vector类模板模
拟一个std::vector,但有正确的同步成员函数。
volatile mt_vector vec:
if (!vec.empty ()){
vec.pop_back();
}
本来意图是去掉一个vector的最后一个元素,如果有的话。上面代码在单线程环境下运行得非常好,但是如果你在多线程程序里用mt_vector,代码
可能抛出意外,即使empty和pop_back已经被正确同步了。所以低层数据(vec)一致性保持正确,但更高层次的操作是错误的。
在经历所有的讨论后,不管怎样,我还是坚持推荐voaltile是个有用的工具来在有类似于POSIX的mutexes的系统上检测竟态条件。但如果你工作在多
处理器系统下,你可能会首先阅读你的文挡。你清楚你该怎么做的。
最后,Kenneth Chiu提及一篇在http://theory.stanford.edu/~freunds/race.ps的非常有趣的文章。猜猜文章题目是什么?“Type-Based Race
Detection for Java”这篇文章描述了,在Java类型系统里增加很少的东西,加上程序员的配合,就可以在编译时检测竟态条件。