有效放置lock_guard - 来自Effective Modern C ++的第16项

时间:2017-01-25 00:44:50

标签: c++ multithreading atomic

在第16项:"使const成员函数线程安全"有一个代码如下:

class Widget {
public:    
  int magicValue() const
  {
    std::lock_guard<std::mutex> guard(m);  // lock m    
    if (cacheValid) return cachedValue;
    else {
      auto val1 = expensiveComputation1();
      auto val2 = expensiveComputation2();
      cachedValue = val1 + val2;
      cacheValid = true;
      return cachedValue;
    }
  }                                        // unlock m    
private:
  mutable std::mutex m;
  mutable int cachedValue;                 // no longer atomic
  mutable bool cacheValid{ false };        // no longer atomic
};

我想知道为什么应该在每次magicValue()调用时始终执行std :: lock_guard,不能按预期工作?:

class Widget {
public:

  int magicValue() const
  {

    if (cacheValid) return cachedValue;
    else {
      std::lock_guard<std::mutex> guard(m);  // lock m
      if (cacheValid) return cachedValue;          
      auto val1 = expensiveComputation1();
      auto val2 = expensiveComputation2();
      cachedValue = val1 + val2;
      cacheValid = true;
      return cachedValue;
    }
  }                                        // unlock m

private:
  mutable std::atomic<bool>  cacheValid{false};
  mutable std::mutex m;
  mutable int cachedValue;                 // no longer atomic
};

这样就需要更少的互斥锁,从而提高代码的效率。我在这里假设atomica总是比互斥量更快。

[编辑]

为了完整性,我测量了两个apraches的效率,第二个看起来只有6%更快:http://coliru.stacked-crooked.com/a/e8ce9c3cfd3a4019

2 个答案:

答案 0 :(得分:3)

您的第二个代码段显示了 Double Checked Locking Pattern (DCLP)的完全有效的实现,并且(可能)Meyers的解决方案(可能)更有效,因为它避免了不必要地锁定mutex设置cachedValue后。

保证昂贵的计算不会多次执行。

此外,cacheValid标志为atomic非常重要,因为它会在写入和阅读cachedValue之间创建发生在之间的关系。 换句话说,它将cachedValue(在mutex之外访问)与调用magicValue()的其他线程同步。 如果cacheValid是常规'bool',那么cacheValidcachedValue上的数据都会发生数据竞争(根据C ++ 11标准导致未定义的行为)。

cacheValid内存操作上使用默认的顺序一致内存排序很好,因为它意味着获取/释放语义。 理论上,您可以通过在atomic加载和存储上使用较弱的内存顺序进行优化:

int Widget::magicValue() const
{

  if (cacheValid.load(std::memory_order_acquire)) return cachedValue;
  else {
    std::lock_guard<std::mutex> guard(m);  // lock m
    if (cacheValid.load(std::memory_order_relaxed)) return cachedValue;
    auto val1 = expensiveComputation1();
    auto val2 = expensiveComputation2();
    cachedValue = val1 + val2;
    cacheValid.store(true, std::memory_order_release);
    return cachedValue;
  }
}

请注意,这只是一个小优化,因为读取atomic是许多平台上的常规负载(使其与从非原子读取一样高效)。

正如Nir Friedman所指出的,这只能单向运作;您无法使cacheValid无效并重新开始计算。但这不是迈耶斯的例子。

答案 1 :(得分:2)

我实际上认为你的代码片段是孤立的,但它依赖于一个在现实世界的例子中通常不正确的假设:它假设cacheValid从false变为true,但永远不会反向进展,即失效。

在旧代码中,mutex保护所有cachedValue上读取写入。在您的新代码中,实际上对互斥锁之外的cachedValue具有读访问权限。这意味着一个线程可以读取该值,而另一个线程正在编写它。问题在于,只有在cacheValid为真时才会在互斥锁之外进行读取。但如果cacheValid为真,则不会发生任何写作; cacheValid只有在所有写入完成后才能成为(请注意,这是强制执行的,因为cacheValid上的赋值运算符将使用最严格的内存排序保证,因此它不能按照块中早先的说明重新排序。

但是假设编写了一些其他代码,可能会使缓存失效:Widget::invalidateCache()。这段代码除了将cacheValid再次设置为false之外什么都不做。在旧代码中,如果您从不同的线程重复调用invalidateCachemagicValue,后一个函数可能会在任何给定点重新计算值。但是,即使你的复杂计算每次被调用时返回不同的值(因为它们使用全局状态,比如说),你将始终获得旧值或新值,而不是其他值。但现在考虑代码中的以下执行顺序:

  1. 线程1调用magicValue,并检查cacheValid的值。这是真的。它会在继续之前被中断。
  2. 线程2调用invalidateCache,然后立即调用magicValuemagicValue发现缓存无效,获取互斥锁并开始计算,开始写入cacheValid
  3. 线程1中断,读取部分写入的cacheValid
  4. 我实际上并不认为此示例适用于大多数现代计算机,因为int通常为32位,通常32位写入和读取将是原子的。所以它不可能散布或者撕裂&#34; cachedValue的值。但是在不同的体系结构上,或者如果你使用的不是整数类型(例如64位以上的类型),写入或读取不能保证是原子的。因此,作为magicValue的返回,你可以获得既不是旧值也不是新值的东西,而是一些甚至不是有效对象的奇怪的按位混合。

    所以,擅长找到这个。我想,为了简单起见这个例子,作者忘了不再需要严格把互斥体放在外面。