volatile:多线程程序员的最好朋友
作者:Andrei Alexandrescu,2001年2月1日
设计volatile关键字是为了防止编译器优化,该优化可能在存在某些异步事件的情况下使代码不正确。
我不想破坏您的心情,但是本专栏讨论了多线程编程这个令人恐惧的话题。如果像Generic的上一期文章所述,异常安全编程很困难,那么与多线程编程相比,这是小孩子的玩法。
众所周知,使用多个线程的程序通常很难编写,证明正确,调试,维护和驯服。不正确的多线程程序可能会运行数年而不会出现故障,但由于已满足某些关键的计时条件,因此只能意外运行amok。
不用说,编写多线程代码的程序员需要她所能获得的所有帮助。本专栏重点讨论竞争条件(多线程程序中常见的故障源),并为您提供有关如何避免它们的见解和工具,而且令人惊讶的是,让编译器努力工作以帮助您解决这一问题。
只是一个小关键字
尽管C和C ++标准在线程方面都非常沉默,但它们确实以volatile关键字的形式对多线程做了一些让步。
就像它最著名的对应const一样,volatile是类型修饰符。它旨在与在不同线程中访问和修改的变量结合使用。基本上,没有volatile,要么编写多线程程序变得不可能,要么编译器浪费了巨大的优化机会。请按要求进行解释。
考虑以下代码:
class Gadget {
public:
void Wait() {
while (!flag_) {
Sleep(1000);
}
}
void Wakeup() {
flag_ = true;
}
...
private:
bool flag_;
};
Gadget :: Wait的目的是每秒检查一下flag_成员变量,并在另一个线程将该变量设置为true时返回。至少这就是程序员的意图,但是,Wait,等待是不正确的。
假设编译器发现Sleep(1000)是对外部库的调用,它无法修改成员变量flag_。然后,编译器得出结论,可以将flag_缓存在寄存器中并使用该寄存器,而不用访问较慢的板载内存。这是对单线程代码的出色优化,但是在这种情况下,它会损害正确性:在调用Wait for a Gadget对象之后,尽管另一个线程调用Wakeup,Wait将永远循环。这是因为flag_的更改不会反映在缓存flag_的寄存器中。优化太...乐观了。
在寄存器中缓存变量是非常有价值的优化,大多数情况下都会应用,因此浪费它是可惜的。C和C ++为您提供了显式禁用此类缓存的机会。如果在变量上使用volatile修饰符,则编译器不会在寄存器中缓存该变量-每次访问都将访问该变量的实际内存位置。因此,要使小工具的“等待/唤醒”组合起作用,您要做的就是适当地限制flag_的资格:
class Gadget {
public:
... as above ...
private:
volatile bool flag_;
};
关于volatile的基本原理和用法的大多数解释都在这里停止,并建议您对在多个线程中使用的原始类型进行volatile限定。但是,由于volatile是C ++出色的类型系统的一部分,因此您可以做更多的事情。
对用户定义类型使用volatile
您不仅可以volatile限定基本类型,还可以对用户定义类型进行volatile限定。在这种情况下,volatile以类似于const的方式修改类型。(您也可以同时将const和volatile应用于同一类型。)
与const不同,volatile区分原始类型和用户定义类型。也就是说,与类不同,基本类型在经过volatile限定时仍支持其所有操作(加法,乘法,赋值等)。例如,可以将非易失性int分配给volatile int,但是不能将非易失性对象分配给volatile对象。
让我们在示例中说明volatile如何对用户定义的类型起作用。
class Gadget {
public:
void Foo() volatile;
void Bar();
...
private:
String name_;
int state_;
};
...
Gadget regularGadget;
volatile Gadget volatileGadget;
如果您认为volatile对对象不是那么有用,请准备一些惊喜。
volatileGadget.Foo();
regularGadget.Foo();
volatileGadget.Bar();
从非限定类型到易失对应类型的转换是微不足道的。但是,就像使用const一样,您不能使旅行从不稳定转变为不合格。您必须使用强制转换:
Gadget& ref = const_cast<Gadget&>(volatileGadget);
ref.Bar();
易失性合格的类仅允许访问其接口的子集,该子集在类实现者的控制下。用户只有使用const_cast才能获得对该类型的界面的完全访问权限。另外,就像常量一样,易失性从类传播到其成员(例如,volatileGadget.name_和volatileGadget.state_是易失性变量)。
易失性,临界区和竞争条件
互斥锁是多线程程序中最简单,最常用的同步设备。互斥锁公开“获取”和“释放”原语。在某个线程中调用Acquire之后,任何其他调用Acquire的线程都会阻塞。稍后,当该线程调用Release时,恰好一个被Acquire调用阻塞的线程将被释放。换句话说,对于给定的互斥锁,只有一个线程可以在调用Acquire和调用Release之间获得处理器时间。在对Acquire的调用与对Release的调用之间的执行代码称为关键部分。(Windows术语有点令人困惑,因为它称互斥体本身为关键部分,而“互斥体”实际上是进程间互斥体。如果将它们称为线程互斥体和进程互斥体,那就太好了。)
互斥体用于保护数据免于竞争条件。根据定义,当更多线程对数据的影响取决于线程的调度方式时,就会发生竞争状态。当两个或多个线程竞争使用同一数据时,出现竞争条件。由于线程可以在任意时间相互中断,因此数据可能会被破坏或解释不正确。因此,更改和有时对数据的访问必须由关键部分仔细保护。在面向对象的编程中,这通常意味着您将互斥锁作为成员变量存储在类中,并在访问该类的状态时使用它。
有经验的多线程程序员可能会打哈欠阅读上面的两段内容,但是它们的目的是提供知识性的锻炼,因为现在我们将与易失性连接链接。为此,我们在C ++类型的世界和线程语义的世界之间画出了一条平行线。
- 在关键部分之外,任何线程都可以随时中断其他任何线程。没有控制,因此从多个线程可访问的变量是易失的。这与volatile的初衷是一致的-防止编译器一次不经意地缓存多个线程使用的值。
- 在由互斥锁定义的关键部分内,只有一个线程可以访问。因此,在关键部分内部,执行代码具有单线程语义。受控变量不再是volatile —您可以删除volatile限定符。
简而言之,线程之间共享的数据在概念上在关键部分外是易失的,而在关键部分内是非易失性的。
您可以通过锁定互斥锁来输入关键部分。您可以通过应用const_cast从类型中删除volatile限定符。如果我们设法将这两个操作放在一起,我们将在C ++的类型系统和应用程序的线程语义之间建立连接。我们可以让编译器为我们检查竞争条件。
锁定点
我们需要一个收集互斥量获取和const_cast的工具。让我们开发一个LockingPtr类模板,使用一个易失对象obj和一个互斥体mtx对其进行初始化。在其生命周期内,LockingPtr保持获取mtx。此外,LockingPtr还提供对易失性剥离的obj的访问。通过operator->和operator *以智能指针方式提供访问。const_cast在LockingPtr内部执行。强制转换在语义上是有效的,因为LockingPtr在其生命周期内保留获取的互斥量。
首先,让我们定义LockingPtr将与之一起工作的Mutex类的框架:
class Mutex {
public:
void Acquire();
void Release();
...
};
要使用LockingPtr,需要使用操作系统的本机数据结构和原始函数来实现Mutex。
LockingPtr使用受控变量的类型进行模板化。例如,如果要控制窗口小部件,则可以使用用volatile窗口小部件类型的变量初始化的LockingPtr。
LockingPtr的定义非常简单。LockingPtr实现了一个简单的智能指针。它仅专注于收集const_cast和关键部分。
template <typename T>
class LockingPtr {
public:
LockingPtr(volatile T& obj, Mutex& mtx)
: pObj_(const_cast<T*>(&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自动对象。让我们用一个例子来说明。
假设您有两个共享矢量对象的线程:
class SyncBuf {
public:
void Thread1();
void Thread2();
private:
typedef vector<char> BufT;
volatile BufT buffer_;
Mutex mtx_;
};
在线程函数内部,您只需使用LockingPtr即可控制对buffer_成员变量的访问:
void SyncBuf::Thread1() {
LockingPtr<BufT> lpBuf(buffer_, mtx_);
BufT::iterator i = lpBuf->begin();
for (; i != lpBuf->end(); ++i) {
... use *i ...
}
}
该代码非常容易编写和理解-每当需要使用buffer_时,都必须创建一个指向它的LockingPtr。完成后,您就可以访问vector的整个界面。
令人高兴的是,如果您犯了一个错误,编译器会指出:
void SyncBuf::Thread2() {
BufT::iterator i = buffer_.begin();
for ( ; i != lpBuf->end(); ++i ) {
... use *i ...
}
}
在应用const_cast或使用LockingPtr之前,您无法访问buffer_的任何功能。区别在于LockingPtr提供了将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_必须是易失的。其次,即使是看似原子的操作(例如++ ctr_)实际上也是一个三阶段操作。内存本身没有算术功能。当增加一个变量时,处理器:
- 在寄存器中读取该变量
- 递增寄存器中的值
- 将结果写回内存
此三步操作称为RMW(读-修改-写)。在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_是易失性的,则编译器将编译++ ctr_,尽管生成的代码根本不正确。编译器不再是您的盟友,只有您的关注可以帮助您避免出现竞争状况。
那你该怎么办 只需封装在高层结构中使用的原始数据,然后在这些结构中使用volatile。矛盾的是,尽管最初这是volatile的使用意图,但直接将volatile与内置函数一起使用更糟!
易失的成员函数
到目前为止,我们已经有了聚合易失数据成员的类。现在让我们考虑设计类,这些类又将成为更大对象的一部分并在线程之间共享。在这里可变成员函数可以提供很大帮助。
在设计类时,只对那些线程安全的成员函数进行volatile限定。您必须假设外部代码会随时从任何代码中调用volatile函数。不要忘记:volatile等于免费的多线程代码,没有关键部分;非易失性等于单线程方案或在关键部分内。
例如,您定义了一个Widget类,它以两种变体来实现操作:一种是线程安全的,另一种是不受保护的快速线程。
class Widget {
public:
void Operation() volatile;
void Operation();
...
private:
Mutex mtx_;
};
注意使用重载。现在,Widget的用户可以使用统一语法来调用Operation,以实现易失性对象并获得线程安全性,或者使用常规语法并获得速度。用户必须谨慎定义共享的Widget对象为volatile。
在实现易失性成员函数时,通常的第一步是使用LockingPtr锁定它。然后,通过使用非易失性同级完成工作:
void Widget::Operation() volatile {
LockingPtr<Widget> lpThis(*this, mtx_);
lpThis->Operation();
}
概要
在编写多线程程序时,可以使用volatile来发挥自己的优势。您必须遵守以下规则:
- 将所有共享对象定义为易失性。
- 不要将volatile直接用于原始类型。
- 定义共享类时,请使用易失成员函数来表示线程安全性。
如果这样做,并且使用简单的通用组件LockingPtr,则可以编写线程安全的代码,而不必担心争用条件,因为编译器会为您担心,并会认真指出错误之处。
我参与的两个项目都使用volatile和LockingPtr产生了很大的效果。该代码是干净且易于理解的。我记得有几个死锁,但是我更喜欢死锁而不是竞争条件,因为它们很容易调试。比赛条件几乎没有问题。但是那时你永远不知道。
致谢
非常感谢James Kanze和Sorin Jianu提出了有见地的想法。
Andrei Alexandrescu是总部位于华盛顿州西雅图的RealNetworks Inc.(www.realnetworks.com)的开发经理,并着有《现代C ++设计》一书。可以通过www.moderncppdesign.com与他联系。Andrei还是C ++研讨会(www.gotw.ca/cpp_seminar)的特色讲师之一。
本文可能有点过时,但确实可以很好地了解如何使用volatile修饰符以及多线程编程,以帮助保持事件异步,同时让编译器为我们检查竞争条件。这可能无法直接回答OP最初有关创建内存防护的问题,但是我选择将其发布为其他人的答案,作为在使用多线程应用程序时良好使用volatile的极好的参考。