什么时候std :: shared_timed_mutex比std :: mutex慢,什么时候(不)使用它?

时间:2018-06-21 15:31:03

标签: c++ multithreading c++14

我正在尝试使用this文章作为提示或灵感,在C ++中实现多线程LRU缓存。它用于Go,但是C ++中也存在或多或少需要的概念。本文建议对散列表和链接列表周围的共享互斥锁使用细粒度锁定。

因此,我打算使用std::unordered_mapstd::list并使用std::shared_timed_mutex锁定来编写缓存。我的用例包括几个线程(4-8),这些线程使用此缓存作为拼写错误的单词和相应可能的更正的存储。缓存的大小约为10000-100000个项目。

但是我在几个地方读到,使用共享的互斥锁而不是普通的互斥锁几乎没有意义,而且速度较慢,尽管我找不到使用数字的真实基准或至少何时以及何时使用模糊的准则。不要使用共享的互斥锁。其他资料来源则建议,只要您有并发读取器,而其并发写入器的数量或多于并发写入器,则可以使用共享互斥体。

  1. 什么时候使用std::shared_timed_mutex比普通的std::mutex更好?读者/读者应该多于作家/作家多少次?我当然知道这取决于许多因素,但是我应该如何决定要使用哪个呢?
  2. 也许它依赖于平台,并且某些平台的实现要比其他平台差? (我们使用Linux和Windows作为目标,MSVC 2017和GCC 5)
  3. 按照本文所述实现缓存锁定是否有意义?
  4. 与定时时钟相比,std::shared_mutex(来自C ++ 17)在性能上有什么不同吗?

PS 。我认为将会有“最适合您的情况的度量/配置文件”。可以,但是我需要首先实施,如果存在一些启发式方法来选择而不是同时执行选项和度量,那将是很棒的。同样,即使我进行了测量,我也认为结果将取决于我使用的数据。而且很难预测实际数据(例如,对于云中的服务器)。

2 个答案:

答案 0 :(得分:1)

  
      
  1. 什么时候使用std::shared_timed_mutex比普通的std::mutex更好?   读者/读者应该多于作家/作家多少次?我当然知道这取决于许多因素,但是我应该如何决定要使用哪个呢?
  2.   

由于它们的额外复杂性,很少有读/写锁(std::shared_mutexstd::shared_timed_mutex)优于普通锁(std::mutexstd::timed_mutex)的情况。它们确实存在,但是就我个人而言,我从未遇到过。

如果您执行频繁但短暂的读取操作,则读/写互斥量不会提高性能。它更适合于读取操作频繁且昂贵的场景。当读取操作仅是内存数据结构中的查找时,最有可能的简单锁定将胜过读取/写入器解决方案。

如果读取操作非常昂贵,并且您可以并行处理许多操作,则在某些时候增加读取与写入的比率应该会导致读取/写入器的性能超过排他锁。那个突破点在哪里取决于实际的工作量。我没有一个好的经验法则。

还要注意,按住锁执行昂贵的操作通常是一个不好的信号。也许可以使用读/写锁来解决此问题。

在该领域比我有更多知识的人对这个话题发表了两条评论:

  • Howard Hinnant的回答C++14 shared_timed_mutex VS C++11 mutex
  • Anthony Williams的报价可以在this answer的末尾找到(不幸的是,指向该原始帖子的链接似乎已失效)。他解释了为什么读/写锁很慢,并且通常不是理想的解决方案。
  
      
  1. 也许它依赖于平台,并且某些平台的实现要比其他平台差? (我们使用Linux和Windows作为目标,MSVC 2017和GCC 5)
  2.   

我不知道操作系统之间的重大差异。我的期望是情况会类似。在Linux上,GCC库依赖glibc的读/写锁实现。如果您想深入了解,可以在pthread_rwlock_common.c中找到实现。它还说明了读/写锁带来的额外复杂性。

Boost(#11798 - Implementation of boost::shared_mutex on POSIX is suboptimal)中的shared_mutex实现存在一个旧问题。但是对于我来说,尚不清楚该实现是否可以改进,或者仅仅是一个不适合读/写锁的示例。

  
      
  1. 按照本文所述实现缓存锁定是否有意义?
  2.   

坦率地说,我对读/写锁将提高这种数据结构的性能表示怀疑。读取器的操作应该非常快,因为它只是查找。更新LRU列表也发生在读取操作之外(在Go实现中)。

一个实施细节。在这里使用链接列表并不是一个坏主意,因为它使更新操作变得非常快(您只需更新指针)。使用std::list时请记住,它通常涉及内存分配,在按住键时应避免使用。最好在获取锁之前分配内存,因为内存分配非常昂贵。

在其HHVM项目中,Facebook具有并发LRU缓存的C ++实现,这看起来很有希望:

  1. ConcurrentLRUCache
  2. ConcurrentScalableCache

ConcurrentLRUCache还对LRU列表使用链接列表(但不对std::list使用),对映射表本身(来自Intel的并发哈希映射实现)使用tbb::concurrent_hash_map。请注意,对于锁定LRU列表更新,它们不像Go实现中那样采用读/写方法,而是使用简单的std::mutex排他锁。

第二个实现(ConcurrentScalableCache)建立在ConcurrentLRUCache之上。他们使用分片来提高可伸缩性。缺点是LRU属性仅是近似值(取决于您使用的分片数量)。在某些工作负载中,可能会降低缓存的命中率,但这是避免所有操作必须共享同一锁的好方法。

  
      
  1. (与C ++ 17相比)std :: shared_mutex(来自C ++ 17)在性能上有什么不同吗?
  2.   

我没有关于开销的基准数字,但是看起来就像在比较苹果和桔子。如果需要计时功能,则别无选择,只能使用std::shared_timed_mutex。但是,如果您不需要它,可以只使用std::shared_mutex,它要做的工作更少,因此永远不会变慢。

在需要超时的典型情况下,我不希望定时开销过大,因为在这种情况下锁往往会保持更长的时间。但是正如我所说,我不能用真实的测量结果来支持这一说法。

答案 1 :(得分:1)

因此,哪些问题实际上可以通过std :: shared_mutex解决。

让我们想象您正在编写一些实时音频软件。您有一些每秒被驱动程序调用1000次的回调,并且您必须将1 ms的音频数据放入其缓冲区中,以便硬件在接下来的1 ms中播放它。而且您有音频数据的“大”缓冲区(比如说10秒),该缓冲区由其他线程在后台渲染并每10秒写入一次。另外,您还有10个线程希望从同一缓冲区读取数据(在UI上绘制内容,通过网络发送,控制外部指示灯等)。这是真正的DJ软件的真正任务,不是开玩笑。

因此,在每个回调调用(每1毫秒)中,您与写入器线程发生冲突的可能性非常低(0.01%),但是与另一读取器线程发生冲突的可能性却几乎为100%-它们起作用一直在读取相同的缓冲区!因此,假设某个线程从该缓冲区读取数据,该线程已锁定std :: mutex并决定通过网络发送内容,然后等待下一个500毫秒的响应-您将被锁定,无法执行任何操作,硬件将不会获得声音的下一部分,它将播放静音(例如,在演唱会上想象一下)。这是一场灾难。

但这是解决方案-对所有读取器线程使用std :: shared_mutex和std :: shared_lock。是的,std :: shared_lock的平均锁定将使您花费更多(假设不是50毫微秒,而是100毫微秒-即使对于您的实时应用来说,这仍然非常便宜,它应该在1毫秒以内写入缓冲区),但是当另一个读取器线程将对性能至关重要的线程锁定500 ms时,即使在最坏的情况下,也可以100%安全。

这就是使用std :: shared_mutex的原因-避免/改善最坏的情况。不能提高平均性能(应该以其他方式实现)。