volatile
关键字用于阻止编译器进行一些在异步事件代码中进行的可能导致错误的优化。
ByAndrei Alexandrescu
February 01,2001
URL:http://drdobbs.com/cpp/184403766
前言
多线程应用程序非常复杂,很难编写,且易错,调试,跟踪难度大,受到广大用户和编程人员的诟病。然而,凡大而复杂的系统都会用到多线程技术,所以我们还是必须直面应用多线程技术带来的一些问题,特别对于程序员来说,更应如此。
本文主要关注竞态——这也是许多多线程程序出现错误的主要诱因之一。我们将介绍怎样去避免竞态,并让编译器去尽量地避免这些竞态条件的产生。
关键字volatile
虽然C和C++标准中都对线程讨论甚少,但它们引入了关键字volatile来支持多线程编程。跟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_
是否被另一个线程设为true
。至少程序员是这么想的,但是,Wait
是错误的。
假设编译器认为Sleep(1000)
是一个对外部库的调用,它不可能修改成员变量
flag_
,那么,编译器就会武断地决定它可以缓存
flag_
的值到某个寄存器中并使用寄存器中的值,而不是去访问相对低速的内存。对于单线程代码来说,这是一个极佳的优化,然而,在本例中,它损害了代码的正确性:当你调用
Wait
等待某个
Gadget
对象时,尽管另一个线程调用了
Wakeup
,但是Wait
还是会永远循环。这是因为
flag_
值的变化并没有同步更新到缓存了
flag_
值的寄存器上。这个优化有点过了。
大部分情况下,将变量缓存到寄存器中是一个非常有价值的优化,所以浪费这个优化也有点可惜。不过,
C
和
C++
允许我们显式地禁用这种优化。如果对一个变量使用
volatile
修饰符,那么编译器将不会缓存变量到寄存器中——每次对变量的访问将会从实际内存中去读取变量的值。所以,为了让Gadget
的
Wait
/Wakeup
协同工作,只需要为flag_
添加一个修饰符:
class Gadget
{
public:
... as above ...
private:
volatile bool flag_;
};
通常这就是关键字volatile
的主要用处,而且一般人在讲这个关键字的用处时也会到此为止。然而,还有更多有关该关键字的一些用处值得我们进一步研究。
用volatile限定用户自定义的类型
关键字volatile
不仅仅是限定基本类型,它还可以限定用户自定义的类型。在这种情况下,
volatile
限定类型的方式与const
类似。
(你也可以对相同类型同时使用关键字const
和
volatile
。)
与const
不同的是
,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 fun called for
// volatile object
regularGadget.Foo(); // ok, volatile fun called for
// non-volatile object
volatileGadget.Bar(); // error! Non-volatile function called for
// volatile object!
一个被volatile
限定的类只暴露了它的一部分接口,这些接口在类的实现者的控制之下。用户只需要使用
const_cast
做个类型转换就可以访问类的其他接口。另外,跟被
const
限定的类一样,类的成员也会变得被
volatile
限定了。
(例如,volatileGadget.name_
和volatileGadget.state_
都是
volatile
变量。
).
从一个非限定的类型转化为volatile限定型非常简单,然而,如同const
一样,你不能将
volatile
限定型转换为非
volatile
限定型。
必须使用如下方式:
Gadget& ref = const_cast<Gadget&>(volatileGadget);
ref.Bar(); // ok
volatile, 关键代码区和竞态条件
在多线程程序中,最简单且最常用的同步工具就是互斥量。互斥量向外提供Acquire
和Release
操作原语。一旦在某个线程中调用
Acquire
,任何其他调用
Acquire
的线程将会被阻塞。然后,当该线程调用
Release
,被阻塞的线程中的一个将会获得互斥量而结束阻塞状态。按句话说,对于任意一个互斥量,仅有一个线程在调用Acquire
和Release
之间获得处理器时间
.在Acquire
和Release
调用之间的代码称为关键代码区。
互斥量用于保护竞态条件下的数据。按照定义,当线程的数量超过线程调度时所依赖的数据的数量时,竞态条件就产生了。当两个或更多的线程竞争使用相同的数据时竞态条件就会出现。由于线程可以在任意时刻中断,数据可能会被破坏或错误解析。因此,对数据的改变或访问有时也必须小心地置于关键代码区内。在面向对象编程中,这通常意味着你将一个互斥量作为类的成员变量,并在任何时候访问类的状态时使用它。
说了一大堆,到底跟volatile
有何联系呢?接下来,我们对
C++
的类型世界与线程的语义世界作个对比:
-
在关键代码区
外
,任何线程可以在任意时刻中断
;
没有任何控制,因此,从多个线程中访问的变量是
volatile
的。这与
volatile
的初衷吻合——阻止编译器无意地将多个线程使用的值缓存。 -
在由互斥量定义的关键代码区
内
,仅有一个线程可以访问。因此,在关键代码区内,执行代码是单线程的。被控制的变量不再是
volatile
的 ——你可以删除volatile
限定符。
简言之,线程间共享的数据在概念上来说,当位于关键代码区外时是volatile
的
,
当位于关键代码区内时,是
non-volatile
的。
当锁定一个互斥量时,就进入了一个关键代码区。可以利用const_cast
来删除
volatile
限定符。当我们将这些操作放在一起,就形成了一个
C++
类型系统与应用程序线程语义之间的一个联系。我们可以让编译器为我们检查竞态条件。
LockingPtr
我们需要一个工具收集互斥量的获取操作以及const_cast
操作。让我们来开发一个
LockingPtr
类模板,你通过一个
被volatile限定的
对象和一个互斥量
mtx
来初始化它。在
LockingPtr
类的生命周期期间,它一直占有
mtx
。同时它提供访问被转化为非
volatile限定
的
obj
。该访问是通过操作符
->
和操作符
*
以智能指针的形式实现的。
const_cast
操作是在类LockingPtr
内部执行的。从语义上来讲,这是合法的,因为类
LockingPtr
在它的整个生命周期内都是拥有互斥量的。
首先,我们来定义一下类Mutex
的基本结构:
class Mutex
{
public:
void Acquire();
void Release();
...
};
为了使用LockingPtr
,你得使用你操作系统的本地数据结构和原语函数来实现
Mutex
。
LockingPtr
是一个类型可控的模板类。例如,如果你想控制一个
Widget
对象,那么可以使用一个变量类型为
volatile Widget
来初始化一个
LockingPtr
<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 ...
}
}
上述代码简单易懂——无论何时你需要使用buffer_
,你必须创建一个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 ...
}
}
你不能访问buffer_
的任何函数,直到你应用const_cast
转换或使用
LockingPtr
。不同之处是
LockingPtr
提供了一种有序的方式对
volatile
变量执行
const_cast
转换。
LockingPtr
表达能力相当强。如果你仅需要调用其中的一个函数,你可以创建一个匿名的临时
LockingPtr
对象并直接使用它:
unsigned int SyncBuf::Size() {
return LockingPtr<BufT>(buffer_, mtx_)->size();
}
回到基本类型
我们已经看到volatile
是怎样保护对象无序地访问以及
LockingPtr
是如何提供一个简单且有效地方式来编写线程安全的代码。现在让我们回到基本类型,
volatile
对它们的处理不同。
让我们考虑如下一个例子,在该例子中多个线程共享一个int
变量。
class Counter
{
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<int>(ctr_, mtx_); }
void Decrement() { —*LockingPtr<int>(ctr_, mtx_); }
private:
volatile int ctr_;
Mutex mtx_;
};
现在代码是正确的,但是与SyncBuf
的代码相比,质量略差了点,为什么?因为使用
Counter
的话,编译器在你错误地直接访问
ctr_
(没有锁定它)时不会给出任何警告。如果ctr_
是volatile
限定型的,编译器不会报出
++ctr_
有错,尽管产生的代码只是一个简单的错误。编译器不再是你的同盟者,你只有倍加小心才能避免竞态产生。
那你应该怎么做呢?将你使用的基本数据封装在更高级的数据结构中并使用volatile
来限定它。耐人寻味的是,直接使用
volatile
来限定一些内建的数据类型被认为是非常不好的做法,尽管这是
volatile
最原始的初衷.
volatile限定型成员函数
到此为止,我们已经拥有了一些拥有多个volatile
限定型的数据成员的类
;
现在让我们反过来考虑一下设计一些类,这些类会成为一些更大的对象的一部分并在不同的线程之间共享。这时,
volatile
限定型的成员函数就会非常有用。
当设计你自己的类时,你可以将那些线程安全的成员函数设为volatile
限定型的。你必须假设在任何时候在任何代码中调用
volatile
限定型的函数。别忘了:
volatile
等同于自由的多线程代码和非关键代码区
;
非
volatile
限定型等同于单线程情形或是一个关键代码区内。
例如,你可以定义一个类Widget
以两种方式实现一个操作——一个线程安全的和一个快速地非线程安全的。
class Widget
{
public:
void Operation() volatile;
void Operation();
...
private:
Mutex mtx_;
};
注意重载的使用。现在Widget
的使用者可以用统一的语法方式调用
Operation
,对
volatile
限定型对象来讲,获得了线程安全性,对一般的对象,获得了速度。
使用者必须小心地将共享的Widget
对象定义为
volatile
限定型的。
当实现一个volatile
限定型的成员函数时,第一个操作通常是用一个
LockingPtr
实例锁定
this
。然后,接下来的工作就是使用非volatile
限定型的同名函数:
void Widget::Operation() volatile
{
LockingPtr<Widget> lpThis(*this, mtx_);
lpThis->Operation(); // invokes the non-volatile function
}
总结
当编写多线程程序时,你可以使用volatile
。你必须坚持如下原则:
-
定义所有共享的对象为
volatile
限定型的。
-
不要对基本类型直接使用
volatile
。
-
当定义共享类时,使用
volatile
限定型的成员函数来表示线程安全。
如果你这样做了,并且你使用本文介绍的这个简单的通用组件LockingPtr
,你就可以写线程安全代码,且不用过多担心竞态条件,因为编译器会为你关注一些竞态条件并且忠实地指出你出错的地方。