单写多读基元映射

Posted

技术标签:

【中文标题】单写多读基元映射【英文标题】:Single writer multiple reader primitives map 【发布时间】:2014-01-04 22:09:28 【问题描述】:

我正在尝试用 Java 编写一个演员实现。我的设计需要一个高性能的地图数据结构来查找特定参与者被安排在哪个线程上。查找是使用 int id 完成的。所有演员都有单独的ID。我有以下要求:

i) 键是原始整数,而不是整数类。

ii) 值也是原语。值只能涵盖在数据结构实例化之前已知的少数数字。值只是线程/核心的 id,因此它可以是 short。线程数小于机器上的内核数,因此无法真正达到很高的数量。

iii)地图由单线程写入,但从多个线程读取。我希望我的实现是无锁的并且没有任何共享(虚假或其他)。因此读取不应涉及对非线程本地内存的任何写入。

iv) 写入次数(由单个线程)将大大超过来自使用映射进行查找的多个读取器线程的读取。

v) 需要的主要操作:

    Set(key, value)delete(key, value) 始终从单个写入线程调用。大多数设置的键最终也会被删除,因此大量删除后的性能不会降低。将使用递增的整数生成新的键(actor-ids),并表示创建了一个actor。删除一个键(一个actor的id)表示该actor已经退出并且永远不会恢复。这也意味着一旦删除的密钥将永远不会再次设置。重要的是我们不要在地图中累积死区,因为会发生删除(演员退出)。

    Get(key) 从阅读器线程调用。

vi) 操作get(key) 需要是eventually consistent,但有一些注意事项。假设编写器线程已将 key1->value1 对更改为 key1->value2。如果其中一个读取器执行 get(key1) 并且仍然收到 value1,这不是问题。最终它应该得到 value2。如果对 key1->value1 已被写入线程删除,并且读取线程上的 get(key1) 仍然返回 value1,也可以。实际上,我的意思是可以合并 Java 的 putOrderedObject/lazySet/getObjectVolatile 或 C++11 的 std::memory_order_relaxed/std::memory_order_acquire/std::memory_order_release 之类的东西。另一方面,如果确实设置了值,get(key1) 不应返回空值(例如 -1)。我不介意有一个getStrict(key1) 操作,如果get(key1) 返回一个空值来满足这个要求,我可以调用它。

我没有立即使用库的原因是:

i) Java 集合:它们需要包装类,因此不符合我的目标 (i) 和 (ii)

ii) Trove、FastUtil 等:它们确实有原始地图,但不提供任何并发访问设施。它们也没有针对稀疏范围内的值进行优化——在我的例子中是核心数。

iii) Java ConcurrentHashMap/ConcurrentSkipListMap:它们需要包装类并且不针对单个写入器、多个读取器的用例进行优化。

我意识到这些要求很多,所以如果答案解决了一些问题,而对其他一些问题保持模棱两可,那很好。将我指向设计中的源代码/代码或 cmets 会很棒。由于我正在尝试学习如何钓鱼,因此任何权衡取舍的解释都将是一个额外的好处。

如果我将我的详细要求归结为几个问题,它们可能是:

i) 如何针对单写入器/多读取器用例进行优化?

ii) 如何设计get(key)getStrict(key) 操作?这是正确的思考方式吗?

iii) 如何设计我的地图以利用递增键和稀疏值范围?

iv) 如何以最佳方式处理频繁删除?任何调整大小/重新散列都需要立即对阅读器线程可见,而不是最终可见。

也欢迎任何带有 C++/C++11 代码的答案。通过一些研究,我应该能够将大多数 std::atomic 操作转换为 Java Unsafe 操作。

【问题讨论】:

一个简单的读写锁怎么样,比如std::shared_lock (C++14)? “高性能”不一定与“无锁”相关。无锁是关于确定性延迟而不是吞吐量。 【参考方案1】:

虚假共享仅来自多个作者,因为您只有一个作者,所以作者之间的共享应该没有问题。

i) 如何针对单写入器/多读取器用例进行优化?

您不需要为多个阅读器做任何特别的事情,每个线程都将拥有数据结构的本地副本。单个编写器是最简单(也是最快)的用例。

因此,Trove 和 ConcurrentMaps 都可以很好地做到这一点。 BTW ConcurrentMap 还针对多个编写器进行了优化。

ii) 如何设计 get(key) 和 getStrict(key) 操作?这是正确的思考方式吗?

您描述的是并发集合现在的工作方式。我不清楚 getStrict 有什么不同。

iii) 如何设计我的地图以利用递增键和稀疏值范围?

如果您有简单的递增键,那么环形缓冲区可能是更好的选择。如果你有sparse values,你需要做的就是存储值。

iv) 如何以最佳方式处理频繁删除?

环形缓冲区对于删除操作非常有效,具体取决于您正在执行的操作。要考虑的主要事情是有一个内存/对象回收策略。这将减少重新分配和垃圾收集的成本。

任何调整大小/重新散列都需要立即对阅读器线程可见,而不是最终可见。

如果值最终可以保持一致,我不明白为什么需要立即调整大小。

【讨论】:

你说的是Java并发映射吗?我需要一个带有原语而不是类的地图。我想到 getStrict 的原因是宽松的原子。如果我使用 putOrderedObject 在一个线程中将键设置为某个值,则它的效果可能在不确定的时间内不可见。由于我不想将设置值读取为缺失,因此我正在考虑一个严格的版本。当单个编写器将 key1->thread-id1 更改为 key2-thread-id2 时,两个线程首先会意识到此更改,因此旧值将通过转发解析为新值。调整大小会导致错误的值。 @Rajiv putOrderedObject 不会比putVolatileObject 花费更长的时间,不同之处在于后者会暂停 CPU 管道,以防您尝试读取刚刚在同一线程中写入的值。 @Rajiv 我不知道有任何集合允许您将一个键更改为另一个键作为原子操作。键在设计上是不可变的。使用单个编写器调整大小非常简单,因为您创建了数据结构的浅表副本,并在完成后将一个引用交换为另一个引用。当您使用多个线程写入/调整大小时,这只是一个潜在问题。 读取旧值很好,但读取错误值则不行,因此需要调整大小以立即可见。您能否详细说明环形缓冲区解决方案?如果 id 的创建和删除顺序相同,那么环形缓冲区会很棒。演员将有不可预测的生命周期。所以演员 id 1,2,3 将按顺序创建,但 3 可能先死,而 1,2 永远不会死。 如果您无法读取旧值,则读取器只需要 volatile 读取(花费大约 5 ns),这取决于您。短命演员很受欢迎,但恕我直言,使用多线程的全部目的是提高性能。如果您不关心性能,您只需使用一个更简单的线程。还有另一种思路,即使这会使程序变慢,也必须使用多个线程来使用所有 CPU,但我不明白这一点。

以上是关于单写多读基元映射的主要内容,如果未能解决你的问题,请参考以下文章

v81.01 鸿蒙内核源码分析(读写锁篇) | 内核如何实现多读单写 | 百篇博客分析OpenHarmony源码

v81.01 鸿蒙内核源码分析(读写锁篇) | 内核如何实现多读单写 | 百篇博客分析OpenHarmony源码

v81.01 鸿蒙内核源码分析(读写锁篇) | 内核如何实现多读单写 | 百篇博客分析OpenHarmony源码

iOS开发:深入理解GCD 第二篇(dispatch_groupdispatch_barrier基于线程安全的多读单写)

CopyOnWriteArrayList(少写多读)

基元类型引用类型和值类型