简而言之在概念上,被多个线程共享的数据在临界区外是volatile,在临界区内是非volatile. 你通过锁一个mutex来进入临界区。你通过使用一个const_cast来去除volatile标识符。你如果把这两个操作放在一起,我们就在C++类型系统和应用程序的线程语义之间建立了一个联系。我们就能够让编译器来为我们检查竟态条件。 LockingPtr 我们需要一个工具来集中一个mutex的获取操作和一个const_cast。我们来开发LockingPtr模板类,你能够用一个volatile对象obj和一个mutex对象mtx来初始化这个模板类。在这个模板类的生存期内,一个LockingPtr保持mtx始终被占用。同时,LockingPtr对去除volatile的obj提供访问。这个访问是用聪明指针方式,通过operator->和operator*来提供。在LockingPtr内执行const_cast,这个转换语义上是有效的,因为LockingPtr在生存期内保持mutex被占用。 首先我们来定义LockingPtr用到的Mutex类的骨架: class Mutex { public: void Acquire(); void Release(); ... }; 为了能使用LockingPtr,你要用你操作系统用到的数据结构和基本函数来实现Mutex。 LockingPtr用受控的变量的类型来作为模板。举例来说,如果你想管理一个Widget,你使用一个LockingPtr,这样你可以用一个类型为volatile Widget的变量来初始化它。 LockingPtr的定义非常简单。LockingPtr实现一个相对简单的smart pointer。它目的只是把一个const_cast和一个临界区集中在一起。 Template Class LockingPtr { Public: //构造/析构函数 LockingPtr(volatile T& obj, Mutex& mtx) : pObj_(const_cast(&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自动对象。我们通过一个例子来说明: 假设你有两个线程共享一个vector对象 class SyncBuf { public: void Thread1(); void Thread2(); private: typedef vector BufT; volatile BufT buffer_; Mutex mtx_; //控制对buffer_的访问 }; 在一个线程函数中,你简单地使用一个LockingPtr来取得对buffer_成员变量的受控访问: void SyncBuf::Thread1() { LockingPtr lpBuf(buffer_, mtx_); BufT::iterator I = lpBuf->begin(); For (; I != lpBuf->end(); ++I) { ...使用*i... } } 这些代码既非常容易写也非常容易懂——任何时候你需要用到buffer_,你必须创建一个LockingPtr指向它。一旦你这样做,你就能够使用vecotr的所有接口。 非常好的事情是,如果你犯了错,编译器会指出来: void SyncBuf::Thread2() { //错误,不能对一个volatile对象调用begin() BufT::iterator I = buffer_.begin(); //错误!不能对一个volatile对象调用end() for (; I != lpBuf->end(); ++I) { ...使用*i... } } 你不能调用buffer_的任何函数,除非你要么使用一个const_cast要么使用LockingPtr。区别是LockingPtr提供了一个有序的途径来对volatile变量使用const_cast。 LockingPtr非常有表现力。如果你只需要调用一个函数,你能够创建一个无名临时LockingPtr对象并直接使用它: Unsigned int SyncBuf::Size() { Return LockingPtr(buffer_, mtx_)->size(); } 回到基本类型 我们已经看到了volatile保护对象不被不受控制地访问时是多么出色,也看到了LockingPtr提供了多么简单和高效的方法来写线程安全的代码。让我们回到基本类型,那些加了volatile后行为与用户自定类型不同的类型 我们来考虑一个例子,多个线程共享一个类型为int的变量。 Class Count { public: ... void Increment() { ++ctr_; } void Decrement() { --ctr_; } private: int ctr_; }; 如果Increment和Decrement被不同线程调用,上面的代码片段是有问题的。首先,ctr_必须是volatile,其次,即使象++ctr_那样看上去是原子操作的函数实际上是一个三步操作。内存本身没有算术能力,当递增一个变量时,处理器: * 读取那个变量到寄存器 * 在寄存器中增加值 * 把结果写回内存 这个三步操作叫做RMW(Read-ModifyWrite 读-改-写)。在执行一个RMW操作的“改” 操作时,为了让其他处理器访问内存,大多数处理器会释放内存总线。 如果那时另一个处理器对同一个变量执行一个RMW操作,我们就有了一个竟态条件;第二个写操作覆盖了第一个的结果。 你也能够用LockingPtr避免这种情况: class Counter { public: ... void Increment() { ++*LockingPtr(ctr_, mtx_); } void Decrement() { --*LockingPtr(ctr_, mtx_); } private: volatile int ctr_; Mutex mtx_; }; 现在代码正确了,但代码质量比较SyncBuf的代码而言差了很多。为什么?因为在Counter里,如果你错误地直接访问ctr_(没有先对它加锁)编译器不会警告你。如果ctr_是volatile,++ctr_也能编译通过,但产生的代码明显是错误的。编译器不再是你的帮手了,只有靠你自己注意才能避免这样的竟态条件。 那你应该怎么做?简单地把你用到的基本数据包装为更高层次的结构,对那些结构用volatile。荒谬的是,尽管本来volatile的用途是用在内建类型上,但实际上直接这样做不是个好主意! volatile成员函数 到目前为止,我们已经有了包含有volatile数据成员的类,现在我们来考虑设计作为更大对象一部分的类,这些类也被多线程共享。在这里用volatile成员函数有很大帮助。 当设计你的类时,你只对那些线程安全的成员函数加voaltile标识。你必须假定外部代码会用任何代码在任何时刻调用volatile函数。不要忘记:volatile等于可自由用于多线程代码而不用临界区,非volatile等于单线程环境或在一个临界区内。 例如,你定义一个Widget类,实现一个函数的两个变化——一个线程安全的和一个快的,无保护的。 Class Widget { public: void Operation() volatile; void Operation(); ... private: Mutex mtx_; }; 注意用了重载。现在Widget的用户可以用同样的语法来调用Operation,无论你为了获得线程安全调用volatile对象的Operation还是为了获得速度调用常规对象的Operation。但用户必须小心地把被多线程共享的Widget对象定义为volatile。 当实现一个volatile成员函数时,第一个操作通常是对this用一个LockingPtr加锁。剩下的工作可以交给非volatile的对应函数: void Widget::Operation() volatile { LockingPtr lpThis(*this, mtx_); LpThis->Operation(); //调用非volatile函数 } 总结 当写多线程程序时,你可以用volatile得到好处。你必须遵守下面的规则: * 定义所有的被共享的对象为volatile。 * 不要对基本类型直接用volatile * 当定义可被共享类时,使用volatile成员函数来表示线程安全。 如果你这样做,而且如果你使用那个简单的返型组件LockingPtr,你能够写出线程安 全的代码而不用更多考虑竟态条件,因为编译器能为你留心,会为你主动指出你错误的地方。 我参与的几个使用volatile和LockingPtr的计划获得很好的效果。代码清晰易懂。我记得碰到几处死锁,但我情愿遇到死锁也不要竟态条件,因为死锁调试起来容易得多。事实上没有遇到任何问题是关于竟态条件的。 致谢 十分感谢James Kanze和Sorin Jianu,他们给了我非常有帮助的深刻的见解。 补充:Volatile实际上被滥用了? 我收到对于我2月份专栏文章“返型<编程>:volatile——多线程程序员的好朋友”的许多反馈。正如往常,我收到的赞誉都来自私人信件,然而抱怨都发在Usenet新闻组comp.lang.c++.moderated和comp.programming上。随后的争论激烈而漫长,如果你对这个主题感兴趣,你可以去看一下。帖子名为“volatile,was memory visibility between threads" 我从那个帖子里也学到了很多。一件事情是,文中开头的Widget例子是错误的。为了长话短说,在有些系统(比如POSIX兼容系统)不需要volatile标识符,另外一些系统加了volatile没有用,程序还是不正确。 最重要的问题是volatile是依赖于类似于POSIX的mutexes设施,有些多处理器系统用mutexes是不够的——你必须用内存屏障(memory barriers)。 另一个更哲学化的问题是,严格说来,把volatile从变量前转换掉是非法的,即使是你自己为了volatile正确性增加了volatile标志。正如Anthony Williams指出的,一个系统可能有足够理由会把volatile数据存放在不同于非volatile数据的地方,这样的地址转换行为错误。 还有另一个批评是volatile的正确性。尽管它在低层解决竟态条件,但无法正确检测更高层的,逻辑上的竟态条件。比如,你有个mt_vector类模板模拟一个std::vector,但有正确的同步成员函数。 volatile mt_vector vec: if (!vec.empty ()){ vec.pop_back(); } 本来意图是去掉一个vector的最后一个元素,如果有的话。上面代码在单线程环境下运行得非常好,但是如果你在多线程程序里用mt_vector,代码可能抛出意外,即使empty和pop_back已经被正确同步了。所以低层数据(vec)一致性保持正确,但更高层次的操作是错误的。 在经历所有的讨论后,不管怎样,我还是坚持推荐voaltile是个有用的工具来在有类似于POSIX的mutexes的系统上检测竟态条件。但如果你工作在多处理器系统下,你可能会首先阅读你的文挡。你清楚你该怎么做的。 最后,Kenneth Chiu提及一篇在http://theory.stanford.edu/~freunds/race.ps的非常有趣的文章。猜猜文章题目是什么?“Type-Based Race Detection for Java”这篇文章描述了,在Java类型系统里增加很少的东西,加上程序员的配合,就可以在编译时检测竟态条件。(本篇补充发表于原文发表后二个月的专栏中) Andrei Alexandrescu 是位于西雅图的华盛顿大学的博士生,也是受到好评的《Modern C++ Design》一书的作者。可以通过www.moderncppdesign.com. 来联系他。Andrei同时也是C++研讨会 (<www.gotw.ca/cpp_seminar>).的一名有号召力的讲师。  
2/2 首页 上一页 1 2 |