volatile:多线程编程的最好伙伴

voaltile关键字被设计成在现存的某个异步事件中防止会导致代码错误的编译器优化。


仅仅是一个小小的关键字

当谈及到线程时,尽管所有的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_成员变量并且当flag_被其他线程设置成true时返回。至少那是编程者的意图,但是,呜~,Wait是错误的。

假设编译器认为Sleep(1000)是从外部库中调用的,这个外部库不可能修改成员变量flag_。那么编译器推断它可以将flag_缓存到寄存器,使用寄存器来代替访问更加缓慢的板上存储器。对于单线程代码这是杰出的优化,但在这个例子中,它有损正确性:在你调用Wait等待Gadget对象后,尽管其它线程调用了Wakeup,Wait仍然会永远循环。这是因为flag_的改变并不会反映到缓存flag_的寄存器里。这个优化太乐观了。

在寄存器内缓存变量是一种要用大多数时间的昂贵的优化,因此浪费时间是很可惜的。C和C++给你显式禁止这样的缓存的机会。如果你使用了volatile修饰变量,编译器不会把那个变量缓存到寄存器里——每次访问都会到那个变量的实际内存位置去。所以你想让Gadget的Wait/Waitup协同工作所要做的是适当地限定flag_:

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

和用户自定义的类型一起使用volatile

   你不仅可以使用volatile限定基本类型,也可以限定用户定义的类型。在那种情况下,volatile以一种类似于const的方式修饰类型。(你也可以同时将const和volatile应用到同一类型。

不像const,volatile会分辨基本类型和用户定义类型。换言之,并不像类一样,当使用volatile限定时基本类型仍旧支持所有的操作(加法、乘法、赋值等)。比如,你可以你可以把非volatile的int型赋值给volatile的int型,但不可以把非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一样,你不能倒过来将volatile类型转换为非限制类型。你必须使用强制转换:
Gadget& ref = const_cast<Gadget&>(volatileGadget);
ref.Bar(); // ok

一个volatile限定的类只允许访问它的接口的子集,子集是在类实现者的控制范围内的。用户只有通过使用const_cast才可以获取那个类型接口的所有访问权限。此外,就像常量属性(constness),易变量属性(volatileness)也可以从类传播到它的成员(例如,volatileGadget.name_和volatileGadget.state_是volatile变量)。


volatile,临界区,和竞态条件
在多线程编程中最简单和最常用的同步机制是互斥量。互斥量暴露出Acquire和Release原语。一旦你在线程中调用Acquire,那么任何其他调用了Acquire的线程都会阻塞。之后,当这个线程调用Release,一个阻塞在Acquire调用的线程恰好被释放。换句话说,对于给定的互斥量,在Acquire调用和Release调用之间只有一个线程可以获得处理器时间。在Acquire调用和Release调用之间执行的代码叫做临界区。(Windows的术语有点让人困惑,因为它称互斥本身是临界区,而”互斥量“实际上是进程间的互斥。如果他们被叫做线程互斥量和进程互斥量或许更好。)

互斥量用于保护数据进入竞态条件。根据定义,当更多线程对数据的影响取决于这些线程被调度的方式时,一个竞态条件就会发生。当两个或多个线程争用同一数据时竞态条件出现。因为线程可以在任意时刻打断对方,所以数据很可能被损坏或者曲解。因此,对数据的访问和修改必须使用临界区小心地保护起来。在面向对象编程中,这通常意味着你要把互斥量作为成员变量存储在类里,并且当你要访问类状态时使用它。

在临界区之外,任何线程在任何时间都可能打断对方;这样没法控制,所以可以被多线程访问的变量是volatile的。这是为了保持volatile的原意——那样防止编译器不知情地缓存多个线程同时使用的值。

在由一个互斥量定义的临界区内,只有一个线程可以访问。因此,在临界区内,执行代码具有单线程的语义。被控制的变量不再是volatile的——你可以移除volatile限定符。

简而言之,处于临界区之外,线程间共享的数据在概念上是volatile的,而在临界区内是非volatile的。

你可以通过锁定互斥量进入临界区。你可以使用const_cast移除volatile限定符。如果我们设法把这两个操作放在一起,我们就可以在C++类型系统和应用程序线程语义之间建立连接。我们可以让编译器为我们检查竞态条件。


LockingPtr
我们需要一个工具来封装互斥量的获取和const_cast。我们来开发一个LockingPtr模板类,你需要使用volatile对象obj和互斥量mtx初始化它。在它的生命周期内,LockingPtr保持mtx可获取的。LockingPtr也提供了对去除volatile属性的变量obj的访问。通过operator->和operator*,提供了智能指针方式的访问。const_cast在LockingPtr内部执行。转换在语义上是有效的,因为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 ...
    }
}
代码很容易书写和理解 —— 无论你什么时候需要使用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提供了一种把const_cast应用到volatile变量的有序方式。


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被不同的线程调用,上面的代码段是有毛病的。首先,crt_必须是volatile。其次,即使是像++ctr_这样表面上看似原子操作的实际上是3个阶段的操作。内存自身没有计算能力。当递增一个变量,处理器执行下面3个步骤:

1)在寄存器中读取那个变量

2)在寄存器中递增值

3)把结果写回内存

这三步操作被称为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的优势。你必须遵守以下规定:

1)把所有的共享对象定义成volatile。

2)不要直接和基本类型一起使用volatile。

3)当定义共享类时,使用volatile成员函数来表示线程安全。














评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值