【问题标题】:Multiple threads accessing std::map in c++在 C++ 中访问 std::map 的多个线程
【发布时间】:2019-12-03 22:46:48
【问题描述】:

我正在编写一个 Minecraft 克隆,我真的很想为世界生成、更新块、光传播等事情实现多线程。 我将所有加载的块存储在哈希映射“chunk_map”中。

在 chunk_map 上放置互斥锁会破坏多线程的全部目的,因为每个线程的大部分工作都是在 chunk_map 上迭代。

如果我的想法是正确的,在地图中插入一个新块应该没有问题(在最坏的情况下,线程可能会跳过刚刚添加的块) 但是删除一个块肯定是个问题。

创建一个使用 shared_ptr 而不是 iterator_type 的哈希映射实现是否可以解决在其他线程迭代该映射时从映射中删除 am 元素的问题? 还是有一些不同的、更简单的方法?

编辑: 我想完全避免同步线程,因为我不希望世界生成、块更新等限制渲染性能。

我希望主线程渲染当前加载的所有块。 此外,我希望“更新程序线程”更新每个加载的块中的每个块,等等。 以及加载和卸载块的“世界线程”。

【问题讨论】:

  • "在地图中插入一个新块应该没有问题"
  • @David 谢谢你的回答!我不知道,我猜这违背了使用哈希映射的目的
  • np。顺便说一句,如果没有更多上下文,很难回答您的实际问题,可能值得描述您想要实现的目标(似乎这是一个 XY 问题meta.stackexchange.com/questions/66377/what-is-the-xy-problem
  • @David 问题已编辑。
  • 只是为了确保您知道这一点:std::map 不是哈希图,而是平衡树(通常实现为红黑树)。如果您想要哈希映射,请改用std::unordered_map

标签: c++ multithreading hashmap


【解决方案1】:

很遗憾,您的问题是基于一个错误的前提:

如果我的想法是正确的,在地图中插入一个新块应该不是问题(在最坏的情况下,线程可能会跳过刚刚添加的块)但是删除一个块肯定是问题。

不,你错了。最坏的情况比这更糟糕。考虑:

  1. 线程 A 构造一个新块。
  2. 线程 A 将该块添加到地图中。
  3. 线程 B 在映射中看到新块的条目。
  4. 线程 B 尝试访问块,但由于代码和重新排序写入的硬件中的优化,它看不到在步骤 1 中进行的写入线程 A。

Boom,你的程序刚刚崩溃。

你推理中的关键缺陷,如果你想成为一名成功的程序员,这是你绝对必须在推理中解决的一个非常常见的缺陷,就是认为如果你违反了明确的规则,唯一可能出错的事情是你可以预见的事情。这种推理方式在计算机编程领域是绝对的死亡。

所以,事实上,绝对最坏的情况比我上面说的还要糟糕。您违反了您正在使用的类的要求,该类的实现将因平台而异。您绝对无法知道会出现什么问题。

【讨论】:

  • 谢谢,我以后会记住这一点。这是我第一次尝试写任何更高级的东西,但我确实误解了很多东西。我现在确保没有多个线程同时访问任何资源。
  • 中间段落的好建议
【解决方案2】:

您可以在多个线程(使用std::packaged_taskstd::async)上生成您的块(更新块或其他),然后使用主线程将结果复制到您的地图中。

在您的案例中,最长的部分不是地图访问,而是数据处理。

【讨论】:

  • 我同意最好的解决方案不涉及共享资源,而是每个线程的资源。那么关键部分就变成了算法,而不是线程安全。
  • 感谢您的回答,我最终让每个线程都使用自己的地图副本,并且工作正常
【解决方案3】:

容器需要深入阅读容器规范,以确定哪些写入操作需要互斥,以及哪些读取操作即使在写入操作期间也应该是安全的。

[啊你说hashmap]

简而言之:

哈希图、向量和字符串是一件很痛苦的事情。可以做一些让你的对象指针保持良好的事情(比如预分配),但即使是单线程的,在插入或删除之后保持引用也很困难。

对于传统的地图、集合、列表:

显然,容器的构造和销毁必须是互斥的。

如果您没有对地图进行任何修改,您当然可以多线程读取任意多的内容。只要你使用 find() 而不是 operator[] 就可以了。

map 和 list 的行为如果你正在插入新对象,那么任何现有的迭代器都保持有效,但在 insert() 期间你不能 find() 或 next() (或其他迭代器算法)(假设插入() 成功,因此通常值得在插入之前进行查找),并且您不能并行执行多个(成功)插入。因此,您需要一个单写多读锁定模型:这可以像原子查找/迭代器++ 计数器和第一个/最后一个查找与任何插入/删除互斥锁一样简单。

如果你要删除,那么,那么任何指向被删除对象的迭代器都会失效,单线程也是如此,但是并行使用会出现更多问题。您的代码需要绝对确定您没有任何迭代器可用于已删除元素。根据您的删除用例,这可能不是问题,也可能是设计杀手。我不明白你为什么要删除你仍然需要/在另一个线程中使用的资源,但是我也不知道你的代码如何知道它是否需要。例如,您可能需要在每个实例中使用原子使用计数器,以了解哪些实例可以随时安全删除。

如果您正在执行其他变异操作,则必须做出自己的设计决策。这些是主要操作,以及我认为它们可以安全使用的方式。

您可以通过更深入的了解将其剪得更紧,但这些通常是良好的性能与复杂性之间的折衷。

这适用于大多数容器,除了 vector 和 string -也适用于 hashmap,但原因不同。但这是因为即使在这两种情况下都是单线程的,如果存储移动,插入操作将使现有迭代器完全无效,并且插入和删除操作会使更高索引处的任何迭代器无效。使用 hashmap 时,问题是在 rebucket 操作之后,您之前知道的几乎所有内容都是无效的。

在这种情况下使用 map 而不是 hashmap 可以更容易有效地锁定,但是您必须确定 log(n) 一般性能损失是否值得一般低锁定优势。或者,安排您的 hashmap 仅包含指针,以便您的代码可以安全地保留指向实际对象的指针,而不是迭代器或指向包含对象的指针 - 这在 hashmap 中是不安全的,并且盲锁所有索引调用。

【讨论】:

  • @FrançoisAndrieux - 这就是您需要锁定的原因。但是策略锁定可以产生比盲目锁定所有读取和写入更好的性能,并且正确记录了 stl 映射和列表的实际线程弱点。在这种情况下使用 map 与 hashmap 可以更容易地有效锁定,然后您必须决定 log(n) 一般性能损失是否值得一般低锁定优势。或者,您的 hashmap 只包含指针,因此您的代码可以安全地保存指向实际对象的指针。
  • @FrançoisAndrieux 嗯,我很好奇你觉得迭代器失效规则不适用于多线程?你是说如果规则说你的迭代器在完成特定的写操作时是好的,那么如果在不同的线程中完成相同的写操作可能就不好了;或者迭代到对象在写操作期间是不安全的,但前后都很好?这两个读数似乎都有些偏执?
  • @FrançoisAndrieux 这不正是我所说的吗?好的,我可能只介绍了某些操作,但我说插入和删除操作必须相互同步,并且必须在单写多读的基础上与迭代数学同步?。
  • 我介绍这些操作是因为它们是我经常做的。
  • 好吧,要么我误解了你写的内容,要么在编辑中发生了一些变化(我没有回去阅读所有的修订)。我猜在阅读 “容器需要深度阅读才能确定哪些写入操作需要互斥,以及即使在写入操作期间哪些读取操作应该是安全的。” 我的第一个解释是“读”和“写”操作与容器有关,与元素无关。用这种解释,这个陈述是错误的,因为规则很简单。也许您只需要澄清那部分以明确您的意思是读/写元素。
猜你喜欢
  • 1970-01-01
  • 1970-01-01
  • 2015-10-25
  • 1970-01-01
  • 1970-01-01
  • 2012-03-08
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
相关资源
最近更新 更多