每周技巧 #197:读锁应当少见

本节阅读量:

本文翻译自 Abseil 官网的 Tip of the Week #197: Reader Locks Should Be Rare

原文最初作为 TotW #197 发布于 2021 年 7 月 29 日。

作者:Titus Winters

更新于 2024 年 4 月 1 日。

快捷链接:abseil.io/tips/197

“啊,置身于阅读的人群中是多么美好。” - Rainer Maria Rilke

absl::Mutex 类多年来一直支持两种锁定方式:

  • 排他锁:恰好一个线程持有锁。
  • 共享锁:有两种模式。如果它们“用于写入”,则使用排他锁;但它们还有另一种模式,允许许多线程“用于读取”地持有锁。

共享锁怎么可能是可接受的?锁的全部意义不就是获得对某个对象的排他访问吗?共享锁的感知价值在于我们需要对底层数据/对象进行只读访问。记住,当两个线程在没有同步的情况下访问同一数据,且其中至少一个访问是写入时,就会出现数据竞争和 API 竞争。如果多个线程只需要读取数据时使用共享锁,并且写入数据时总是使用排他锁,我们就可以避免读者之间的争用,同时仍然避免数据竞争和 API 竞争。

为支持这一点,absl::Mutex 同时提供 Mutex::Lock()(以及 Mutex::WriterLock(),它是相同行为的另一个名称)和 Mutex::ReaderLock()。从这些接口看,你可能会以为当我们只读取锁保护的数据时,应该优先使用 ReaderLock()

很多情况下你会错。

ReaderLock 很慢

ReaderLock 本质上比标准排他锁需要更多簿记和额外开销。因此,很多情况下使用更专门的形式(共享锁)实际上会造成性能损失,因为锁机制本身要多做不少工作。在没有争用时,这个成本较小;但在短临界区有争用时,ReaderLock 的表现不如 Lock。而没有争用时,ReaderLock 提供的价值本来也没那么重要。

考虑排他锁与共享锁中的逻辑。共享锁通常也必须有排他锁模式;如果没有写者,就不会发生数据竞争,也就根本不需要加锁。因此共享锁天生更复杂,需要检查是否有其他读者持有锁,或者修改读者的原子计数等。

共享锁何时有用?

共享锁主要在锁会被持有相对较长时间,并且多个读者很可能并发获取共享锁时才有收益。例如,如果你在持锁期间会做很多工作(比如遍历一个大容器,而不是只做一次查找),共享锁方案可能有价值。主导问题不是“我是否在写数据”,而是“我预计读者会持锁多久(相对于获取锁所需时间)?”

1
2
3
4
5
6
7
// 这不好:锁内完成的工作量微不足道。
// 使用读锁增加的复杂度,整体上会比允许多个线程并发调用此函数所节省的
// 争用成本更高。
int Foo::GetElementSize() const {
  absl::ReaderMutexLock l(&lock_);
  return element_size_;
}

即使锁内执行的计算量较大,读锁变得更有用,我们也常常会发现有更好的专用接口来完全避免争用。更多内容见 https://abseil.io/fasthttps://abseil.io/docs/cpp/guides/synchronization。RCU(“Read Copy Update”)抽象在这里尤其常见,它能让读取路径基本免费。

我们应该怎么做?

留意 ReaderLock 的使用;绝大多数使用实际上都是悲观优化……但我们无法静态地确定这一点并把代码重写成排他锁。(在 C++ 中推理并发性质,对大多数重构工作来说仍然太难。)

如果你看到 ReaderLock,尤其是新的使用,请试着问:“这个锁下的计算是否经常很长?”如果只是从容器中查找一个值,排他锁几乎肯定是更好的方案。

最终,性能分析可能是唯一能确定的方式;争用跟踪在这里尤其有价值。

每周技巧 #188:小心智能指针函数参数

上一节

每周技巧 #198:标签类型

下一节