volatile —多线程程序员的最好朋友
作者:Andrei Alexandrescu
原文:http://www.cuj.com/documents/s=7998/cujcexp1902alexandr/
--------------------------------------------------------------------------------
就像广为人知的const一样,volatile也是一个类型标志符。它与那些需要在不同线程中访问和修改的变量一起使用。没有volatile,即使不能说编写多线程程序是不可能的,但使编译器浪费大量的优化机会却是不争的事实。下面让我一一道来。
考虑下面的代码:
class Gadget
{
public:
void Wait()
{
while (!flag_)
{
Sleep(1000); // sleeps for 1000 milliseconds
}
}
void Wakeup()
{
flag_ = true;
}
...
private:
bool flag_;
};
上面Gadget::Wait的目标是每秒检查flag_成员变量,在另一个线程把flag_设置为true时返回,最少编写这段代码的程序员是这个意思,但是,这是不正确的。
假设编译器发现Sleep(1000)调用的是外部库的函数并且不可能修改成员变量flag_,编译器觉得自己应该把flag_缓存到寄存器中,每次访问这个变量都访问寄存器而不是慢速的内存,这对单线程代码是一个优秀的优化设计,但在这种情况下,它损害了正确性:在你调用Wait后,尽管另一个线程调用了Wakeup,Wait还是会永远循环下去。这是因为flag_的变化并不会反映到缓存flag_的寄存器中。这个优化有点......"太优化"了。
把变量缓存到寄存器中在大多数时间是一个非常有价值的优化,浪费掉它是不可取的。C和C++给了你一个机会,通过显式的声明来禁止这种缓存。如果你用volatile标志符来修饰一个变量,编译器就不会把这个变量缓存到寄存器中-每次访问这个变量都会访问它在内存中的实际位置。这样,要使Gadget的Wait/Wakeup能够工作,你需要做的就只是正确的标识flag_。
class Gadget
{
public:
... as above ...
private:
volatile bool flag_;
};
volatile的基本原理和使用就解释到这儿,建议你在多线程编程时用volatile标识基本类型变量。然而,你还可以用volatile做多得多的事情,因为它是C++的丰富多彩的类型系统的一部分。
对用户自定义类型使用volatile
你不仅可以用volatile标识基本类型,也可以用它标识用户自定义类型,在这种情况下,volatile使用和const类似的方式修饰变量。(你可以同时使用const和volatile来修饰一个变量)
与const不同,volatile区分基本类型和用户自定义类型,也就是说,与类不同,在被volatile修饰时,基本类型还支持所有与其相关的操作(加,乘,赋值等)。例如,你可以把一个非volatile的整型变量赋给一个volatile的整型变量,但是你不能把一个非volatile对象赋给一个volatile对象。
我们来看一个volatile用于用户自定义类型的例子
class Gadget
{
public:
void Foo() volatile;
void Bar();
...
private:
String name_;
int state_;
};
...
Gadget regularGadget;
volatile Gadget volatileGadget;
如果你认为volatile不是那么有用的话,就准备着被吓一跳吧
volatileGadget.Foo(); // ok, volatile对象调用volatile方法
regularGadget.Foo(); // ok, 非volatile对象调用volatile方法
volatileGadget.Bar(); // 错误! volatile对象不能调用非volatile方法!
从非volatile类型变换到volatile类型是隐含的,但是,与const一样,你不能直接把一个volatile类型转换成非volatile类型,在这种情况下,你必须使用强制转换
Gadget& ref = const_cast<Gadget&>(volatileGadget);
ref.Bar(); // ok
一个volatile标识的类只把访问权限给与自己接口的一个子集,一个在实现者的控制之下的一个子集。用户只有使用一个const_cast才能得到对这个类型接口的完全访问权限。另外,与const相似,volatile从类扩展到类的成员(例如,volatileGadget.name_和volatileGadget.state_ 就是volatile变量)
volatile,关键区和竞争条件
在多线程编程中最简单也是最常用的同步机制是mutex。一个mutex向外提供Acquire和Realse两个方法,一旦你在某个线程中调用了Acquire方法,其它线程调用Acquire方法将会被阻塞。以后,当这个线程调用Release后,以前阻塞的线程中的一个的Acquire调用将会被释放,也就是说,对于一个mutex,在调用Acquire和Release之间的代码,同时只有一个线程能够获得处理器时间,在调用Acquire和调用Release之间的代码叫做一个关键区。(Windows的术语稍微有些混淆,它把mutex自己叫做关键区,而实际上mutex只是进程之间的互斥锁,如果它们被称为线程互斥体或进程互斥体的话就比较明确了)
mutex用来在线程或进程竞争过程中保护数据。根据定义,当多个线程对数据的操作结果取决于线程的调度过程时就会发生竞争。当两个或多个线程要求使用同一份数据时,竞争就出现了。因为线程相互之间可以在一个随机的时间中断对方,这样数据就可能被损坏,所以,更改数据,甚至在有些情况下访问数据都必须用关键区小心地保护起来。在面向对象编程中,这往往意味着你把一个mutex存在一个类的成员变量里,任何时候你访问类的状态时,都要首先使用它。
有经验的多线程程序员可能觉得上面两段很无聊,但他们的目的是提供一个智力测验,因为我们现在就要联系volatile,我们这样做就引出了C++类型和线程术语这两个看似相互无关的东西。
在关键区外部,任何线程都可以在任何时间中断其它线程,没有什么控制,这样多个线程都可以访问的变量的值是不可预测的。这就是最初提出volatile的意图-阻止编译器无意的缓存多线程使用的变量的值。
在由mutex定义的关键区内部,同时只有一个线程可以访问。所以,在关键区内部的代码,具有但线程的语境,变量不再是不可预测的了-你可以去掉volatile标识符。简单的说,多线程间共享的数据,在关键区外是不可预测的,而在关键区内则不是。
你可以通过锁住一个mutex进入一个关键区,通过使用const_cast去掉一个类型的volatile标识符。如果我们试着把这两个操作放在一起,我们就在c++的类型系统和应用程序的线程术语之间建立了一个连接,我们可以让编译器为我们检查竞争。
LockingPtr
我们需要一个工具来收集mutex和const_cast。我们来写一个LockingPtr的类模版,用一个volatile对象obj和一个mutex mtx初始化,在他的生命周期内,LockingPtr保持着要求mtx的状态。LockingPtr也提供到剥离了volatile标识符的对象的访问。这个访问使用一个安全指针,通过操作符->和*来访问。const_cast在LockingPtr内部执行。因为LockingPtr在自己的生命周期内都保持着木特性,这个强制转换在语法上是合法的。
首先,我们定义Mutex类的骨架
class Mutex
{
public:
void Acquire();
void Release();
...
};
为了使用LockingPtr,你必须用本地操作系统的数据结构和函数实现Mutex。
LockingPtr是以要控制的变量的类型为参数的模版,例如,如果你想要控制Widget,使用LockingPtr<Widget>,然后用一个volatile类型的Widget变量初始化。
LockingPtr的定义非常简单。LockingPtr实现了一个简单的安全指针,他只是简单地收集const_cast和关键区。
template <typename T>
class LockingPtr {
public:
// Constructors/destructors
LockingPtr(volatile T& obj, Mutex& mtx)
: pObj_(const_cast<T*>(&obj)),
pMtx_(&mtx)
{ mtx.Lock(); }
~LockingPtr()
{ pMtx_->Unlock(); }
// Pointer behavior
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<char> 对象:
class SyncBuf {
public:
void Thread1();
void Thread2();
private:
typedef vector<char> BufT;
volatile BufT buffer_;
Mutex mtx_; // controls access to buffer_
};
在一个线程的函数中,只是使用LockingPtr<BufT>来得到对于成员变量buffer_的访问控制权。
void SyncBuf::Thread1() {
LockingPtr<BufT> lpBuf(buffer_, mtx_);
BufT::iterator i = lpBuf->begin();
for (; i != lpBuf->end(); ++i) {
... use *i ...
}
}
这里的代码很容易写也很容易理解--任何时候当你需要使用buff_时,你必须创建一个LockingPtr<BufT>指向它,如果你创建成功,你就可以访问vector的全部接口。
这段代码的优点是如果你有错误,编译器会指出来
void SyncBuf::Thread2() {
// Error! Cannot access ‘begin' for a volatile object
BufT::iterator i = buffer_.begin();
// Error! Cannot access ‘end' for a volatile object
for (; i != lpBuf->end(); ++i) {
... use *i ...
}
}