什么情况下std::shared_timed_mutex比std::mutex慢,何时(不)使用它?

11
我正在尝试使用C++实现一个多线程LRU缓存,参考了这篇文章作为提示或灵感。虽然这篇文章是针对Go的,但所需概念在C++中也存在。该文章建议在哈希表和链表周围使用细粒度锁定和共享互斥锁。
因此,我打算使用std::unordered_mapstd::liststd::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)与定时互斥锁相比,在性能上有任何区别吗?
P.S. 我感觉会有“首先测量/分析哪种最适合你的情况”的建议。我会这样做,但我需要先实现一个,并且如果存在一些启发式方法来选择而不是实现两个选项并进行测量,那将是很好的。即使我进行了测量,我认为结果将取决于我使用的数据。对于云服务器等实际数据可能很难预测。
2个回答

12
  1. 什么时候使用 std::shared_timed_mutex 比普通的 std::mutex 更好?读者/读取次数应该超过写入者/写入次数多少倍?当然,我知道这取决于许多因素,但是我应该如何做出决策选择哪个锁?

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

如果您经常进行短时间读取操作,则读/写互斥锁不会提高性能。它更适用于读操作频繁且昂贵的场景。当读操作仅是内存数据结构中的查找时,简单的锁很可能优于读/写解决方案。
如果读操作非常昂贵并且您可以同时处理多个读操作,则增加读与写的比率应该会导致读/写锁在某些情况下优于独占锁。这个临界点取决于实际工作负载,我不知道有没有好的经验法则。
此外,请注意,在持有锁时执行昂贵的操作通常是一个不好的标志。可能有更好的方法来解决问题,而不是使用读/写锁。
以下是两位比我更了解该领域的人对此主题的评论:
  • Howard Hinnant回答了C++14 shared_timed_mutex VS C++11 mutex的问题。
  • Anthony Williams的引用可以在this answer的末尾找到(不幸的是,原帖的链接似乎已经失效)。他解释了为什么读写锁很慢,而且通常不是理想的解决方案。
  1. 也许这取决于平台,有些平台的实现比其他平台更差?(我们将Linux和Windows作为目标,使用MSVC 2017和GCC 5)
我不知道操作系统之间有显著的差异。 我期望情况将是类似的。 在Linux上,GCC库依赖于glibc的读/写锁实现。 如果您想深入了解,可以在pthread_rwlock_common.c中找到实现。 它还说明了使用读/写锁带来的额外复杂性。
Boost中的shared_mutex实现存在一个旧问题(#11798 - Implementation of boost::shared_mutex on POSIX is suboptimal)。 但我不确定实现是否可以改进,或者它只是一个不适合读/写锁的例子。
  1. 按照文章所述实现缓存锁定是否有意义?
坦白说,我对于在这样的数据结构中使用读/写锁来提高性能持怀疑态度。读操作应该非常快,因为它只是一个查找操作。更新LRU列表也发生在读操作之外(在Go实现中)。
一个实现细节。在这里使用链表并不是一个坏主意,因为它使得更新操作非常快(你只需要更新指针)。当使用std::list时,请记住它通常涉及内存分配,而当你拥有关键字时应避免这种情况。最好在获取锁之前分配内存,因为内存分配是昂贵的。
在他们的HHVM项目中,Facebook有一些看起来很有前途的并发LRU缓存的C++实现:
  1. ConcurrentLRUCache并发LRU缓存
  2. ConcurrentScalableCache并发可扩展缓存

ConcurrentLRUCache还使用了一个链表(但不是std::list)作为LRU列表,并且使用Intel的tbb::concurrent_hash_map作为映射本身的实现(一种并发哈希映射实现)。请注意,对于LRU列表更新的锁定,他们没有采用Go实现中的读/写器方法,而是使用了一个简单的std::mutex独占锁。

第二个实现(ConcurrentScalableCache)基于ConcurrentLRUCache。它们使用分片来提高可伸缩性。缺点是LRU属性只是近似的(取决于使用多少个分片)。在某些工作负载中,这可能会降低缓存命中率,但这是一个很好的技巧,可以避免所有操作都必须共享同一个锁。

  1. 与定时锁相比,std::shared_mutex(来自C++17)在性能上有什么区别吗?

我没有关于开销的基准数字,但看起来像是在比较苹果和橙子。如果您需要计时功能,则除了使用std::shared_timed_mutex之外别无选择。但是,如果您不需要它,则可以简单地使用std::shared_mutex,因为它需要做更少的工作,因此永远不应该更慢。

对于需要超时的典型场景,我不希望计时开销太严重,因为在这种情况下锁 tend to be hold longer. 但是,正如我所说,我无法用真实的测量数据支持这个说法。


非常感谢您提供如此详细和精彩的答案!当时我已经失去了在任何地方获得提示的希望,只好使用了共享互斥锁和读写锁。现在我理解起来,这可能比直接使用互斥锁劣势更大。但幸运的是,当我不得不添加一个长时间和昂贵的读取操作来转储整个缓存内容到流中时,它仍然具有优点。你永远不知道)也许应该有更好的解决方案,也许有一天我会回来看看,但现在我仍然可以在转储时使用缓存进行读取。 - Roman Kruglov

3

那么,std::shared_mutex 实际上可以解决哪些问题呢?

假设你正在编写一些实时音频软件。你有一些回调函数,由驱动程序每秒调用1000次,并且你必须将1毫秒的音频数据放入其缓冲区中,以便硬件在下一个1毫秒内播放它。你还有一个“大”音频数据缓冲区(假设为10秒),由后台的其他线程渲染并每10秒钟写入一次。此外,您还有10个线程希望从同一个缓冲区中读取数据(以在 UI 上绘制一些内容,通过网络发送,控制外部灯光等)。这是真正的 DJ 软件的真正任务,不是玩笑。

因此,在每次回调调用(每1毫秒)时,您很少会与写入线程发生冲突(0.01%),但是您几乎有100%的机会与另一个读取器线程发生冲突 - 它们一直在工作并读取相同的缓冲区!因此,让我们假设一些从该缓冲区读取数据的线程锁定了 std::mutex 并决定通过网络发送某些内容,然后等待下一个500毫秒的响应 - 您将被锁定,无法做任何事情,您的硬件将无法获得下一部分声音并播放静音(例如在某些音乐会上)。这是一场灾难。

但是这里有一个解决方案-对于所有读取器线程,请使用 std::shared_mutex 和 std::shared_lock。是的,std::shared_lock 的平均锁定成本会更高(假设不是50纳秒,而是100纳秒-即使对于您的实时应用程序来说,这仍然非常便宜,因为最多应该在1毫秒内写入缓冲区),但是您可以 100% 安全地避免另一个读取器线程通过500毫秒锁定您的性能关键线程。

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


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