volatile: 多线程程序员最好的朋友

volatile关键字用于阻止编译器进行一些在异步事件代码中进行的可能导致错误的优化。

ByAndrei Alexandrescu
February 01,2001
URL:
http://drdobbs.com/cpp/184403766

前言

多线程应用程序非常复杂,很难编写,且易错,调试,跟踪难度大,受到广大用户和编程人员的诟病。然而,凡大而复杂的系统都会用到多线程技术,所以我们还是必须直面应用多线程技术带来的一些问题,特别对于程序员来说,更应如此。

本文主要关注竞态——这也是许多多线程程序出现错误的主要诱因之一。我们将介绍怎样去避免竞态,并让编译器去尽量地避免这些竞态条件的产生。

关键字volatile

虽然CC++标准中都对线程讨论甚少,但它们引入了关键字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_值的寄存器上。这个优化有点过了。

大部分情况下,将变量缓存到寄存器中是一个非常有价值的优化,所以浪费这个优化也有点可惜。不过,CC++允许我们显式地禁用这种优化。如果对一个变量使用volatile修饰符,那么编译器将不会缓存变量到寄存器中——每次对变量的访问将会从实际内存中去读取变量的值。所以,为了让GadgetWait/Wakeup协同工作,只需要为flag_添加一个修饰符:

class Gadget
{
public:
    ... as above ...
private:
    volatile bool flag_;
};

通常这就是关键字volatile的主要用处,而且一般人在讲这个关键字的用处时也会到此为止。然而,还有更多有关该关键字的一些用处值得我们进一步研究。


volatile限定用户自定义的类型

关键字volatile不仅仅是限定基本类型,它还可以限定用户自定义的类型。在这种情况下,volatile限定类型的方式与const类似。(你也可以对相同类型同时使用关键字constvolatile)

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, 关键代码区和竞态条件

在多线程程序中,最简单且最常用的同步工具就是互斥量。互斥量向外提供AcquireRelease操作原语。一旦在某个线程中调用Acquire,任何其他调用Acquire的线程将会被阻塞。然后,当该线程调用Release,被阻塞的线程中的一个将会获得互斥量而结束阻塞状态。按句话说,对于任意一个互斥量,仅有一个线程在调用AcquireRelease之间获得处理器时间.AcquireRelease调用之间的代码称为关键代码区。

互斥量用于保护竞态条件下的数据。按照定义,当线程的数量超过线程调度时所依赖的数据的数量时,竞态条件就产生了。当两个或更多的线程竞争使用相同的数据时竞态条件就会出现。由于线程可以在任意时刻中断,数据可能会被破坏或错误解析。因此,对数据的改变或访问有时也必须小心地置于关键代码区内。在面向对象编程中,这通常意味着你将一个互斥量作为类的成员变量,并在任何时候访问类的状态时使用它。

说了一大堆,到底跟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_;
};

如果IncrementDecrement是从不同的线程中调用的,上述代码是有问题的。首先,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,你就可以写线程安全代码,且不用过多担心竞态条件,因为编译器会为你关注一些竞态条件并且忠实地指出你出错的地方。



转载于:https://my.oschina.net/fuyajun1983cn/blog/263788

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值