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_值的寄存器上。这个优化有点过了。

大部分情况下,将变量缓存到寄存器中是一个非常有价值的优化,所以浪费这个优化也有点可惜。不过,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 objectregularGadget.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/destructorsLockingPtr(volatile T& obj, Mutex& mtx): pObj_(const_cast<T*>(&obj)),pMtx_(&mtx){    mtx.Lock();    }~LockingPtr(){    pMtx_->Unlock();    }// Pointer behaviorT& 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 objectBufT::iterator i = buffer_.begin();// Error! Cannot access 'end' for a volatile objectfor (; 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

volatile: 多线程程序员最好的朋友相关推荐

  1. volatile——多线程程序员最好的朋友

    volatile修正符及让你的编译器为你检查竞态条件(race conditions) 并不是我故意想弄糟你的心情,但是在这期专栏里,我们将讨论多线程编程这一话题.正如上一期Generic里所说的,编 ...

  2. 程序员广交四海朋友群

    群一:71923869 群二:85907325 程序员俱乐部,是由程序员自发组织的.非营利性质的.民间组织,旨在为周边地区广大程序员.编程爱好者和高校计算机爱好者提供一个融洽而稳定的结交朋友.交流技术 ...

  3. 女程序员如何在朋友圈报喜-笑的我肚子疼

    11. 前后端分离开发. 12. 照着文档一步一步做到了最后一步. 13. 最牛叉的代码 14. 当年学C语言的过程. 15. 测试环境一切ok,马上上线 16.千万别乱动老项目 17. 上年纪的前端 ...

  4. 程序员的一往情深朋友圈1

    1.为你编辑快乐的程序,给你安装如意的系统,帮你解开幸福的密码,与你下载好运的软件,程序员节到了,一切准备就绪,愿你程序员节心情无限好,笑口合不拢. 2.滚滚红尘,物欲横流.灯红酒绿,纸醉金迷.不过世 ...

  5. “力挺Java!拒绝Python”9万程序员刷爆朋友圈……

    那天,被一个应届生小伙伴问到:第一份工作选Java还是Python好? 我可以说"我认为编程语言永远是工具,用的顺手,选啥都行!" "没有最好,只有最适合自己." ...

  6. 架构之道:3个程序员成就微信朋友圈日均10亿发布量

    前言 截止到2015年7月,微信每月活跃用户约5.49亿,朋友圈每天的发表量(包括赞和评论)超过10亿,浏览量超过100亿.得益于4G网络的发展,以上数据仍有很快的增长,而且相对于PC互联网时代,移动 ...

  7. 写给想当程序员的朋友

    谨以此文献给所有想当程序员的朋友  (一) 文章由来及个人经历  我是一名计算机专业的本科毕业生,毕业已经1年多了.毕业后从事的是软件编程工作,经常有其他专业的朋友想从事软件编程工作,向我请教如何,因 ...

  8. (转)写给想当程序员的朋友

    --――一个还不太老的程序员的体会 (初稿) 软件以程序员为本(<程序员>) 谨以此文献给所有想当程序员的朋友 (一) 文章由来及个人经历 我是一名计算机专业的本科毕业生,毕业已经1年多了 ...

  9. 写给想当程序员的朋友们

    谨以此文献给所有想当程序员的朋友  (一) 文章由来及个人经历  我是一名计算机专业的本科毕业生,毕业已经 1 年多了.毕业后从事的是软件编程工作,经常有其他专业的朋友想从事软件编程工作,向我请教如何 ...

最新文章

  1. 英语 语义分割_Padlex数据处理-语义分割-分段变换,PaddleX,segtransforms
  2. 05-CA/TA编程:hmac demo
  3. FTP and Firewalls
  4. 朗读评价语言集锦_干货 | 教师课堂评价规范用语的几点建议,建议收藏!
  5. Java 11:字符串类中的新方法
  6. Python version 3.3 required, which was not found in the registry
  7. Simulink嵌入式自动代码DSP F28335(4)——SVPWM
  8. HTTP请求方式和幂等性
  9. 智慧供应链的学习笔记(库存管理、配补货、仓间调拨、控制塔等)
  10. 编译原理常用简称或英文原称(思维导图形式)
  11. 洛谷P1308统计单词数C语言
  12. 国外14部经典励志电影推荐
  13. ARM Cortex-M 调试器 - 基础知识
  14. 【沐风老师】3DMAX一键生成圣诞树建模插件使用教程
  15. “知识共享”扎根中国,前景无量
  16. python在地图上标注点_怎样用python画地图上的标注线
  17. TCP/IP四层模型、HTTP、HTTPS、TCP
  18. Linux 系统注册系统服务流程
  19. uni-app入门:wxs基本使用
  20. 基于LSTM的美国大选的新闻真假分类【NLP 新年开胃菜】

热门文章

  1. HTML+JS弹出可移动DIV遮罩层
  2. 局域网中,ip可以访问其他计算机,“网络”中无法发现共享计算机,也无法通过主机名访问。...
  3. LFS、BLFS、ALFS、HLFS的区别
  4. 简单的图片处理servlet
  5. iOS之UI--涂鸦画板实例
  6. node debug包
  7. 华为防火墙USG基本配置
  8. Html.ActionLink 几种重载方式说明及例子
  9. FORK()子进程对父进程打开的文件描述符的处理
  10. Flex的NumericStepper控件