每周技巧 #197:读锁应当少见
本节阅读量:本文翻译自 Abseil 官网的 Tip of the Week #197: Reader Locks Should Be Rare。
原文最初作为 TotW #197 发布于 2021 年 7 月 29 日。
更新于 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 提供的价值本来也没那么重要。
考虑排他锁与共享锁中的逻辑。共享锁通常也必须有排他锁模式;如果没有写者,就不会发生数据竞争,也就根本不需要加锁。因此共享锁天生更复杂,需要检查是否有其他读者持有锁,或者修改读者的原子计数等。
共享锁何时有用?
共享锁主要在锁会被持有相对较长时间,并且多个读者很可能并发获取共享锁时才有收益。例如,如果你在持锁期间会做很多工作(比如遍历一个大容器,而不是只做一次查找),共享锁方案可能有价值。主导问题不是“我是否在写数据”,而是“我预计读者会持锁多久(相对于获取锁所需时间)?”
|
|
即使锁内执行的计算量较大,读锁变得更有用,我们也常常会发现有更好的专用接口来完全避免争用。更多内容见 https://abseil.io/fast 和 https://abseil.io/docs/cpp/guides/synchronization。RCU(“Read Copy Update”)抽象在这里尤其常见,它能让读取路径基本免费。
我们应该怎么做?
留意 ReaderLock 的使用;绝大多数使用实际上都是悲观优化……但我们无法静态地确定这一点并把代码重写成排他锁。(在 C++ 中推理并发性质,对大多数重构工作来说仍然太难。)
如果你看到 ReaderLock,尤其是新的使用,请试着问:“这个锁下的计算是否经常很长?”如果只是从容器中查找一个值,排他锁几乎肯定是更好的方案。
最终,性能分析可能是唯一能确定的方式;争用跟踪在这里尤其有价值。