可以在用户自定义类型中使用volatile关键字来帮助编写线程安全的代码吗?

24
我知道,在之前的几个问题/答案中已经很清楚地指出,volatile与C++内存模型的可见状态有关,而不是与多线程有关。
另一方面,这篇由Alexandrescu撰写的articlevolatile关键字用作编译时检查,以强制编译器拒绝接受可能不安全的代码,而不是作为运行时特性。在文章中,该关键字更像是required_thread_safety标记,而不是volatile的实际预期用途。
这种对volatile的(滥用)使用是否恰当?这种方法可能隐藏哪些潜在问题?
首先想到的是增加混淆: volatile与线程安全无关,但由于没有更好的工具,我可以接受它。
文章的基本简化:
如果声明一个变量为volatile,只能调用volatile成员方法,因此编译器将阻止调用其他方法的代码。将std::vector实例声明为volatile将阻止使用该类的所有操作。通过形状为锁定指针的包装器添加const_cast以释放volatile要求,将允许通过锁定指针进行任何访问。
从文章中获取:
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&);
};

class SyncBuf {
public:
   void Thread1() {
      LockingPtr<BufT> lpBuf(buffer_, mtx_);
      BufT::iterator i = lpBuf->begin();
      for (; i != lpBuf->end(); ++i) {
         // ... use *i ...
      }
   }
   void Thread2();
private:
   typedef vector<char> BufT;
   volatile BufT buffer_;
   Mutex mtx_; // controls access to buffer_
};

注意

在出现了前几个答案之后,我认为我必须澄清一下,因为我可能没有使用最合适的词语。

volatile 的使用不是因为它在运行时提供了什么,而是因为它在编译时的含义。也就是说,如果用户定义的类型中像 volatile 一样很少使用 const 关键字,那么同样的技巧可以使用 const 关键字来实现。也就是说,有一个关键字(碰巧拼写为 volatile),允许我阻止成员函数调用,Alexandrescu 正在利用它来欺骗编译器,使其无法编译线程不安全的代码。

我认为这是许多元编程技巧之一,它们存在并不是因为它们在编译时做了什么,而是因为它们强制编译器为您执行某些操作。


2
他们正在comp.lang.c++.moderated讨论那段代码。 - Johannes Schaub - litb
2
@Johannes:这是他们每十年都要进行两次讨论吗?我还记得Andrei发表这篇文章时引起的激烈讨论。就像这里一样,大部分争议都是由于人们仅仅读到了volatile和“线程”在同一篇文章中出现,而没有试图理解其背后的想法。 - sbi
8个回答

6
我认为问题并不在于volatile提供的线程安全性。这也不是Andrei文章中所说的。在这里,使用mutex来实现线程安全。问题是,是否滥用了volatile关键字,以提供静态类型检查,同时使用mutex来编写线程安全代码?我认为这很聪明,但我遇到过一些开发人员,他们不喜欢仅仅因为这个要进行严格类型检查
在我看来,当你为多线程环境编写代码时,已经有足够的警惕性强调了,在这种情况下,你会期望人们不会忽略竞争条件和死锁。
这种包装方法的缺点是,使用LockingPtr包装的类型的每个操作都必须通过成员函数进行。这将增加一级间接引用,可能会对团队中的开发人员产生相当大的影响。
但是,如果你是一个信奉C++精神即严格类型检查的纯粹主义者,那么这是一个不错的选择。

1
+1. 我不同意额外的间接层带来的不适。任何进行多线程编程的人都应该知道锁定互斥量是最耗费时间的操作。相比于锁本身,额外的间接层几乎不会产生什么影响。 - David Rodríguez - dribeas
1
@dribeas:我指的是语法上的不适。相信我,有些开发人员不喜欢编写包装代码,而更喜欢自然直观的简单直接代码。 - Abhay

4
这段代码可以捕获一些线程不安全的代码(并发访问),但会忽略其他问题(由于锁定倒置而导致的死锁)。两者都不容易测试,因此只是部分成功。在实践中,记住强制执行仅在某些指定锁定下访问特定私有成员的约束条件对我来说并不是一个大问题。
对这个问题的两个答案已经证明了你的正确性,即混淆是一个重大的缺点 - 维护人员可能已经被强烈地训练成理解volatile的内存访问语义与线程安全无关,以至于他们甚至不会在声明代码/文章不正确之前阅读其余部分。
我认为Alexandrescu在文章中提到的另一个重要缺点是它不能与非类类型一起使用。这可能是一个难以记住的限制。如果你认为将数据成员标记为volatile可以防止在不加锁的情况下使用它们,然后期望编译器在需要时告诉你何时加锁,那么你可能会意外地将其应用于int或模板参数相关类型的成员。结果是产生了错误的代码,尽管编译通过了,但你可能已经停止检查这种错误的代码。想象一下,如果可以将值赋给const int,但程序员仍然期望编译器会为他们检查常量正确性,那么会发生什么样的错误,特别是在模板代码中...
我认为应该注意并排除数据成员类型实际上具有任何volatile成员函数的风险,尽管可能会有人在某一天被咬到。
我在想编译器是否可以通过属性提供额外的const类型修饰符。 Stroustrup说,“建议仅使用属性来控制不影响程序含义但可能有助于检测错误的事物”。 如果您可以将代码中所有关于volatile的提及替换为[[__typemodifier(needslocking)]],那么我认为它会更好。这样就无法在没有const_cast的情况下使用该对象,希望您在考虑丢弃内容时不要写const_cast

3
他破解了C ++类型系统,赋予“volatile”一个新的含义。另外一点:像这样去除“volatile”,例如LockingPtr(volatile T& obj, Mutex& mtx) : pObj_(const_cast<T*>(&obj)), pMtx_(&mtx),根据标准第6.7.3(5)节,这是未定义行为,不是吗? - stephan
1
@dribeas:嗯,在我脑海中刚刚发明的[[__typemodifier(X)]]的定义中,const_cast可以删除任何类型修饰符,包括用户自定义的修饰符,就像它当前删除constvolatile一样。由于用户定义的类型修饰符在语言中除了防止不兼容的赋值之外没有任何意义,因此只要您不以违反程序中该修饰符所暗示的不变量的方式使用结果,这是“安全”的。我还没有对这个想法进行彻底的同行评审,或者说我思考这个想法的时间还不到打字的时间;-) - Steve Jessop
1
@stephan:可能是这样,但Alexandrescu是在2001年写的。现在是2010年,使用线程仍然是未定义行为(希望不久了!)。对于这些东西,你完全取决于你的实现。也许他的文章应该适当地解决这个问题,但我可以原谅他没有说出来。 - Steve Jessop
3
这不是真的。线程不仅仅是一个库。它们需要在运行多线程的整个代码中得到编译器的支持,例如确保内存访问不会被某些编译器变换重排序越过内存屏障。这就是 volatile 的历史作用:它防止访问被重排序跨越对未知代码的调用。对话框不需要应用程序中的所有代码都被编译并对行为进行特殊限制(至少,不包括模态或完全异步的对话框 - 其他线程的回调对话框当然是另一回事)。 - Steve Jessop
1
抱歉,那不是很正确的,volatile 防止访问在调用未知代码时被重新排序。它防止对象被写入不出现在代码中的值(例如,如果编译器知道它将在下一次读取之前无条件地被覆盖,则使用内存作为临时存储器),因此是 C 中实现线程安全内存模型的可能途径之一。在未知代码的调用之间进行这种技巧通常会被阻止,因为存在别名的可能性。 - Steve Jessop
显示剩余5条评论

2

C++03 §7.1.5.1p7:

如果尝试通过非volatile限定类型的lvalue引用一个使用volatile限定类型定义的对象,则程序行为是未定义的。

由于您示例中的buffer_被定义为volatile,因此强制转换会导致未定义的行为。但是,您可以通过适配器来解决这个问题,将对象定义为非volatile,但添加易变性:

template<class T>
struct Lock;

template<class T, class Mutex>
struct Volatile {
  Volatile() : _data () {}
  Volatile(T const &data) : _data (data) {}

  T        volatile& operator*()        { return _data; }
  T const  volatile& operator*() const  { return _data; }

  T        volatile* operator->()        { return &**this; }
  T const  volatile* operator->() const  { return &**this; }

private:
  T _data;
  Mutex _mutex;

  friend class Lock<T>;
};

在IT技术中,需要通过已锁定的对象来严格控制非易失性访问。

template<class T>
struct Lock {
  Lock(Volatile<T> &data) : _data (data) { _data._mutex.lock(); }
  ~Lock() { _data._mutex.unlock(); }

  T& operator*() { return _data._data; }
  T* operator->() { return &**this; }

private:
  Volatile<T> &_data;
};

例子:

struct Something {
  void action() volatile;  // Does action in a thread-safe way.
  void action();  // May assume only one thread has access to the object.
  int n;
};
Volatile<Something> data;
void example() {
  data->action();  // Calls volatile action.
  Lock<Something> locked (data);
  locked->action();  // Calls non-volatile action.
}

有两个注意点。首先,您仍然可以访问公共数据成员(Something::n),但它们将被限定为volatile;这可能会在各个时点失败。其次,Something不知道是否真的已定义为volatile,如果在方法中强制转换掉volatile(从“this”或成员中)仍将是未定义行为:

Something volatile v;
v.action();  // Compiles, but is UB if action casts away volatile internally.

主要目标已经实现:对象不必意识到它们以这种方式使用,除非您明确通过锁来调用非易失性方法(对于大多数类型而言,这是所有方法)。请保留HTML标签。

2
基于其他代码的构建,并完全消除了 volatile 说明符的需要,这不仅有效,而且正确地传播 const(类似于 iterator vs const_iterator)。不幸的是,它需要相当多的样板代码来处理两种接口类型,但您不必重复任何方法逻辑:每个方法仍然只定义一次,即使您必须类似于常规对 const 和非 const 方法的重载“复制”“volatile”版本。
#include <cassert>
#include <iostream>

struct ExampleMutex {  // Purely for the sake of this example.
  ExampleMutex() : _locked (false) {}
  bool try_lock() {
    if (_locked) return false;
    _locked = true;
    return true;
  }
  void lock() {
    bool acquired = try_lock();
    assert(acquired);
  }
  void unlock() {
    assert(_locked);
    _locked = false;
  }
private:
  bool _locked;
};

// Customization point so these don't have to be implemented as nested types:
template<class T>
struct VolatileTraits {
  typedef typename T::VolatileInterface       Interface;
  typedef typename T::VolatileConstInterface  ConstInterface;
};

template<class T>
class Lock;
template<class T>
class ConstLock;

template<class T, class Mutex=ExampleMutex>
struct Volatile {
  typedef typename VolatileTraits<T>::Interface       Interface;
  typedef typename VolatileTraits<T>::ConstInterface  ConstInterface;

  Volatile() : _data () {}
  Volatile(T const &data) : _data (data) {}

  Interface       operator*()        { return _data; }
  ConstInterface  operator*() const  { return _data; }
  Interface       operator->()        { return _data; }
  ConstInterface  operator->() const  { return _data; }

private:
  T _data;
  mutable Mutex _mutex;

  friend class Lock<T>;
  friend class ConstLock<T>;
};

template<class T>
struct Lock {
  Lock(Volatile<T> &data) : _data (data) { _data._mutex.lock(); }
  ~Lock() { _data._mutex.unlock(); }

  T& operator*() { return _data._data; }
  T* operator->() { return &**this; }

private:
  Volatile<T> &_data;
};

template<class T>
struct ConstLock {
  ConstLock(Volatile<T> const &data) : _data (data) { _data._mutex.lock(); }
  ~ConstLock() { _data._mutex.unlock(); }

  T const& operator*() { return _data._data; }
  T const* operator->() { return &**this; }

private:
  Volatile<T> const &_data;
};

struct Something {
  class VolatileConstInterface;
  struct VolatileInterface {
    // A bit of boilerplate:
    VolatileInterface(Something &x) : base (&x) {}
    VolatileInterface const* operator->() const { return this; }

    void action() const {
      base->_do("in a thread-safe way");
    }

  private:
    Something *base;

    friend class VolatileConstInterface;
  };

  struct VolatileConstInterface {
    // A bit of boilerplate:
    VolatileConstInterface(Something const &x) : base (&x) {}
    VolatileConstInterface(VolatileInterface x) : base (x.base) {}
    VolatileConstInterface const* operator->() const { return this; }

    void action() const {
      base->_do("in a thread-safe way to a const object");
    }

  private:
    Something const *base;
  };

  void action() {
    _do("knowing only one thread accesses this object");
  }

  void action() const {
    _do("knowing only one thread accesses this const object");
  }

private:
  void _do(char const *restriction) const {
    std::cout << "do action " << restriction << '\n';
  }
};

int main() {
  Volatile<Something> x;
  Volatile<Something> const c;

  x->action();
  c->action();

  {
    Lock<Something> locked (x);
    locked->action();
  }

  {
    ConstLock<Something> locked (x);  // ConstLock from non-const object
    locked->action();
  }

  {
    ConstLock<Something> locked (c);
    locked->action();
  }

  return 0;
}

将类Something与Alexandrescu对volatile的使用进行比较:
struct Something {
  void action() volatile {
    _do("in a thread-safe way");
  }

  void action() const volatile {
    _do("in a thread-safe way to a const object");
  }

  void action() {
    _do("knowing only one thread accesses this object");
  }

  void action() const {
    _do("knowing only one thread accesses this const object");
  }

private:
  void _do(char const *restriction) const volatile {
    std::cout << "do action " << restriction << '\n';
  }
};

1

从不同的角度来看待这个问题。当你将一个变量声明为const时,你告诉编译器该值不能被你的代码更改。但这并不意味着该值不会改变。例如,如果你这样做:

const int cv = 123;
int* that = const_cast<int*>(&cv);
*that = 42;

......这将引发未定义的行为,但在实践中会发生一些事情。也许值会被改变。也许会出现sigfault。也许会启动飞行模拟器——谁知道呢。关键是你不知道在平台无关的基础上会发生什么。因此,表面上const承诺没有得到实现。该值实际上可能是常量,也可能不是。

既然如此,使用const是否滥用语言?当然不是。它仍然是语言提供的工具,帮助您编写更好的代码。它永远不会是确保值保持不变的全部工具,程序员的大脑最终是那个工具,但这是否使const无用呢?

我认为不是,使用const作为帮助您编写更好代码的工具并不是滥用语言。事实上,我会更进一步地说,这是该功能的意图

现在,volatile 的情况也是如此。将某个东西声明为 volatile 不会使您的程序线程安全。它甚至可能不会使该变量或对象线程安全。但是编译器将强制执行 CV 限定符语义,谨慎的程序员可以利用这个事实来帮助他通过帮助编译器识别可能写入错误的地方来编写更好的代码。就像编译器在他尝试执行以下操作时帮助他一样:

const int cv = 123;
cv = 42;  // ERROR - compiler complains that the programmer is potentially making a mistake

忘记内存栅栏和易失性对象和变量的原子性,就像你早已忘记了cv的真正常数性一样。但是使用语言提供给你的工具来编写更好的代码。其中一个工具是volatile


0

你最好不要这样做。 volatile 关键字甚至不是为了提供线程安全而发明的,它是为了正确访问内存映射硬件寄存器而发明的。 volatile 关键字对 CPU 的乱序执行特性没有影响。你应该使用适当的操作系统调用或 CPU 定义的 CAS 指令、内存栅栏等。

CAS

内存栅栏


仅凭提供的代码,我看不出将BufT设置为volatile有什么好处。整个线程安全问题现在是Mutex的问题。由于BufT是私有成员,出于性能原因,最好不要将其设置为volatile。但是我也看到你的代码存在问题。一旦Thread1锁定了Mutex,Thread2将永远无法访问BufT,因此它将卡在其主体的第一行... - Malkocoglu
2
如果你仔细阅读文章和代码,就会发现变量在使用时并不是“volatile”(限定词通过“const_cast”被移除了)。这只是一个编译时的技巧。正如@Malkocoglu所指出的那样,整个线程安全都是通过互斥锁来正确处理的。我想混淆才是这种方法的最大缺点。 - David Rodríguez - dribeas
我本来犹豫是否要给这个评论点个踩,但最终还是这么做了。因为文章和这个帖子中并没有使用volatile来确保乱序执行按照一定的方式进行。它的作用是利用编译器的类型系统,所以你的评论与问题无关。 - John Dibling
1
我讨厌那些标题具有误导性的长问题;-) - Malkocoglu
我尽力避免误导,但很难为此写一个合适的标题。如果您有任何建议,我会接受的。 - David Rodríguez - dribeas

0
在这篇文章中,关键字更像是一个 `required_thread_safety` 标签而不是 volatile 实际预期的用途。
没有读过这篇文章,为什么安德烈不使用所述的 `required_thread_safety` 标签呢?在这里滥用 `volatile` 不是个好主意。我认为这会导致更多的混淆(就像你说的),而不是避免它。
话虽如此,即使它不是一个足够条件,在多线程代码中有时候需要使用 `volatile`,只是为了防止编译器优化依赖于值的异步更新的检查。

使用volatile而不是标签的原因是,类型系统可以在编译时使用它来标记无效访问。既然您提到了这一点,我需要考虑一下是否基于标签的方法可行... - David Rodríguez - dribeas
@David:我认为这应该可以工作,但我也承认可能更难实现。首先,你需要实现一个智能指针接口。也许吧。我还没有仔细考虑过。 - Konrad Rudolph

-2

我不确定Alexandrescu的建议是否可靠,但是尽管我非常尊重他作为一个超级聪明的人,但他对于volatile语义的处理表明他已经远离了自己的专业领域。在多线程中,volatile没有任何价值(请参见这里以获取有关此主题的良好处理),因此Alexandrescu声称volatile对于多线程访问是有用的,这让我严重怀疑我可以在他的文章中放多少信心。


2
我认为你误解了这篇文章,volatile关键字被使用的唯一原因是因为它在编译时所暗示的含义,而不是在运行时。在运行时,所有使用(编译)都会在操作实例之前删除volatile限定符:由于所有使用都通过LockPtr内部的const_cast指针进行,因此编译后的代码中没有volatile - David Rodríguez - dribeas
1
从你链接的文章中,“Hans Boehm指出,volatile只有三个可移植的用途。” Hans Boehm是错误的(虽然他不应该为此感到羞耻)- Alexandrescu提出了第四种使用volatile的方法,即依赖于其类似const的传染行为而不是与内存访问相关的语义。 - Steve Jessop
@David:这篇文章留给解释的余地很少:“它旨在与在不同线程中访问和修改的变量一起使用。基本上,如果没有volatile,编写多线程程序要么变得不可能,要么编译器会浪费大量的优化机会。”我理解文章的主要观点是将volatile用作一种正交的const限定符。我从未反对过这一点。我只是对一个显然不理解其原始意图的人撰写的关于volatile的文章表示了保留。 - Marcelo Cantos
2
Alexandrescu 将两件事混为一谈:volatile 的定义和编译器实际使用 volatile 的方式(他特别提到了 MS)为 C 和 C++ 添加线程支持。多线程不能仅作为库来实现,它需要语言支持,至少在 Windows 上,部分语言支持以 volatile 的附加语义的形式出现。具有一致性缓存的情况下,volatile 访问与内存屏障大致相同。无论 Alexandrescu 是出于无知还是简单起见模糊了这个区别,我不知道。 - Steve Jessop

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接