【问题标题】:Initialize costly value in map with minimum lock contention (C++)用最小的锁争用初始化 map 中的代价高昂的值 (C++)
【发布时间】:2010-10-15 03:03:20
【问题描述】:

假设我们有一个在多个线程之间共享的映射。它表示存储在磁盘上的某种层次结构(例如,文件系统中的目录)中的节点。构造一个值在时间和内存上都是昂贵的。这是经典的“按需初始化”问题,但有一个转折点:当有查找请求时,我们正在初始化映射中的值,但我们不想在执行过程中锁定整个映射这样做是为了让其他线程访问已经构造的值。在此应用程序中,现有值的查找将比不存在的值更多更常见。

尝试1:在map上获取写锁,检查Key是否存在,如果存在则返回,否则,构造Value,放入map中。

评估:这可以防止其他线程在我们构建值时访问映射。由于读取非常普遍且速度非常快,这将表现为延迟的丑陋跳跃:不好。

尝试2:在map上获取读锁,检查Key是否存在,如果存在则返回,否则释放读锁,构造Value,获取写锁,检查是否存在,如果不存在,放在地图中,如果存在删除新构造的值。

评估:现在我们不会引起读取延迟的跳跃,但我们最终可能会不必要地在内存中构造多个相同值的表示(当多个线程尝试查找相同的值时) -同时未构造的值)。问题是,我们不希望这样:创建这些值真的成本很高。此外,它们可能会开始触发事件或执行 I/O,这意味着现在我们必须处理允许存在临时但重量级的 Value 实例的设计:更好地避免额外的头痛。

尝试3:使用两级锁。获取 map 上的读锁,查找 Key,如果存在则返回,否则,释放 map 上的读锁,获取 map 上的写锁,检查占位符,获取占位符的读锁,如果存在,否则,创建占位符,获取其写锁,插入进入map,释放map上的写锁,创建Value,用Value替换占位符,释放占位符的写锁。

评估:在构造 Value 的同时将占位符插入映射中,保证只有一个线程会尝试它,从而解决尝试 2 的问题。但是,这种设计留下了一个悬而未决的问题:什么 form 这个占位符应该取什么?答案并非微不足道。首先,如果它包含一个锁并且线程等待它,则很难删除它(你怎么知道没有人持有占位符的锁?你可以抓住它,当然,但是正在持有它所以,同样,你不能删除它)。其次,可以查找磁盘上不存在值的键。如果我们为 每个 查找尝试插入一个占位符(即使对于最终会失败的查找尝试),那么谁来清理这些?最后,有了两级锁,代码就变得非常难看:我们首先获取读锁,检查,然后获取写锁,重新检查,我们需要在映射级别和单个占位符级别(简单造成死锁,我可以证明)。

这个小宝贝不断出现,我似乎找不到一个优雅的解决方案。尝试 3 是我最接近满足我的性能条件的一次,但是一旦你深入到肮脏的细节,它就会变得丑陋且容易出错。如果有任何见解或建议,我将不胜感激。

【问题讨论】:

  • 从共享映射中读取了多少线程?有多少人给它写信?你说创造价值是昂贵的。复制一份怎么样?
  • 显然有一个“2b”——只有在获取写锁并验证对象不存在后才创建值,但我假设你已经打折了,因为持有写锁太久了。如果您的性能要求有必要,听起来您必须选择#3。对象是否已经有一个锁来控制并发访问?互斥体可以吗还是需要读写?使用互斥锁,可能会将锁定的值“外壳”放入映射(释放映射的写锁),然后恢复值的初始化?一个没有太多 init 的额外构造函数可以促进这一点。
  • QPS(每秒查询数)为数百。线程数十几个左右。当任何线程意识到缺少键并尝试创建相应的值时,它都可以成为“编写者”。值不可复制。
  • Tony:这会在地图中留下一半的构建对象。我可以在 Value 中使用 WaitForInitDone() 开始每个方法,但如果可能的话,我宁愿不这样做。 (特别是因为我无法对 Value 的子类强制执行此操作。)

标签: c++ initialization locking


【解决方案1】:

似乎目标是在查找现有对象时实现快速周转,但会以创建新对象为代价。这里有一个解决方案可以为您做到这一点。

您需要在内存中维护两个映射,它们最终将具有相同的内容。您还需要一个互斥锁来读写: - curReadMap * - curWriteMap * - 写互斥 - 读取互斥体

这两个指针很重要,我们将交换它们。现在读取您拥有的值(粗略的伪代码):

  lock( readMutex )
  value = checkValueIn( curReadMap, key )
  unlock()
  if( value ) return value

如果没有找到值则可以进入写作部分

  lock( writeMutex )
  value = checkValueIn( curWriteMap, key ) //double check now
  if( value ) return value

  value = createNewValue()
  putIn( curWriteMap, key, value )

  lock( readMutex )
  swap( curWriteMap, curReadMap )
  unlock( readMutex )

  putIn( curWriteMap, key, value ) //update old map now as well
  unlock( writeMutex )

在这个方案中,典型的读者只承担一个互斥锁和在映射中查找的成本。如果没有找到对象,他们只会承担创建成本。映射指针的交换还确保了 readMutex 上的锁定时间非常短。

【讨论】:

  • 我考虑过类似的事情。实际上,您甚至不需要第二张地图。只要有一个 create_lock ,所有线程在尝试创建新对象时都必须获取它。我发现这不令人满意有两个原因:我们不能并行创建对象(因为它们需要一段时间才能创建它是一个无赖),并且有太多的 lock()/unlock() 调用符合我的口味(尤其是因为锁获取是交错的)。
  • 你在写你可以避免第二张地图。这会将插入时间放在读锁内。这可能不是问题(取决于地图有多大)。你不应该害怕交错锁:这些锁总是以相同的顺序获取的,在这种情况下不用担心死锁。如果您确实需要并行创建,并且您不能让对象被创建两次,那么您将不得不转移到具有更多锁和/或条件变量的更复杂的系统。
【解决方案2】:

方案#3 的问题在于如何跟踪占位符对象。如果您愿意放弃一点并行性,您可以更轻松地做到这一点 - 分配一个锁,该锁将序列化每个映射的价值创造。然后是:

lookup(map, key):
  1. Grab read lock on map
  2. lookup key in map, unlock & return if present
  3. grab lock on map.create_serializer
  4. lookup key in map, unlock & return if present
  5. release read lock on map
  6. create value for key
  7. grab write lock on map
  8. insert (key,value) into map
  9. release write lock on map
 10. release lock on map.create_serializer

create_serializer 确保在任何时候不超过一个线程正在创建要放入此映射的对象。多个线程同时查找相同的缺失键将在第 3 步进行序列化 - 第一个线程将继续构建该值,其余线程将找到已在第 4 步构建的值。

这将(不必要地)为同一张地图序列化不同的价值创造,但在其他方面满足您的标准。

【讨论】:

    猜你喜欢
    • 1970-01-01
    • 2013-12-24
    • 2016-12-12
    • 2015-06-03
    • 2021-01-29
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 2023-03-04
    相关资源
    最近更新 更多