并发数据结构设计

时间:2008-11-04 16:02:27

标签: c++ data-structures concurrency

我正在尝试提供用于高吞吐量C ++服务器的最佳数据结构。数据结构将用于存储从几个到几百万个对象的任何内容,并且不需要排序(尽管可以非常便宜地提供唯一的排序键)。

要求是它可以支持有效的插入,理想的是O(1),适度有效的移除和有效的遍历。它不需要支持查找操作(除了可能需要删除)。

扭曲是它在修改时必须是线程安全的,而其他线程枚举数据结构。这意味着一个简单的红黑树不起作用,因为一个线程无法插入元素(并执行必要的树旋转)而不会弄乱其他线程持有的任何游标。

可以使用读/写锁并将写操作推迟到所有读取器完成之后,因为读取操作可能很长时间。如果有读取器的插入对读者是否可见,则无关紧要。

内存占用也非常重要,小显然更好!

有什么建议?

对评论的回应:

感谢您的回答。

不,插入不能使现有迭代器无效。迭代器可能会也可能不会看到新的插入,但是如果插入没有发生,它们必须看到它们会看到的所有内容。

删除是必需的,但是由于更高级别的规则,我可以保证迭代器永远不会停止在可以删除的项目上。

锁定游标的每个节点会对性能产生太大影响。可能有多个线程同时读取,并且多个线程在锁中使用的任何类型的内存热点都会占用内存带宽(因为我们发现了很难的方法!)。即使是一个简单的多线程调用InterlockedIncrement的读者也无法完全扩展。

我同意链表可能是最好的方法。删除是罕见的,因此支持后向指针以支持O(1)删除的内存代价是昂贵的,我们可以根据需要单独计算它们,因为删除往往是批处理操作。

幸运的是,只要在更改头指针之前在插入的节点中更新指针,插入链表就不需要对读者进行任何锁定。

锁定 - 复制 - 解锁的想法很有趣。所涉及的数据量太大,无法作为读者的默认工作,但当它们与读者发生冲突时,它可以用于作家。读/写锁将保护整个结构,如果数据结构与读取器发生冲突,则写入将克隆数据结构。写作比读取要少得多。

11 个答案:

答案 0 :(得分:12)

就个人而言,我非常喜欢在高度并发的情况下持久的不可变数据结构。我不知道C ++的具体内容,但Rich Hickey已经在Java中为Clojure创建了一些优秀(且速度极快)不可变的数据结构。具体来说:vector,hashtable和hashset。它们不太难移植,因此您可能需要考虑其中之一。

为了进一步阐述,持久性不可变数据结构确实解决了许多与并发相关的问题。因为数据结构本身是不可变的,所以多个线程同时读取/迭代没有问题(只要它是一个const迭代器)。 “写入”也可以是异步的,因为它不是真正写入现有结构,而是创建包含新元素的该结构的新版本。由于您实际上没有复制所有内容,因此该操作有效(在所有Hickey的结构中 O(1))。每个新版本与旧版本共享其大部分结构。与简单的写时复制技术相比,这可以提高内存效率,并显着提高性能。

使用不可变数据结构,实际需要同步的唯一时间是实际写入引用单元格。由于内存访问是原子的,即使这通常也是无锁的。这里唯一需要注意的是你可能会丢失线程之间的数据(竞争条件)。由于并发性,数据结构永远不会被破坏,但这并不意味着在两个线程基于单个旧版本创建结构的新版本并尝试编写其结果的情况下,不可能出现不一致的结果(其中一个将会“胜利”,其他的变化将会丢失)。要解决此问题,您需要锁定“编写操作”,或使用某种STM。我喜欢低冲突系统中易用性和吞吐量的第二种方法(写入理想情况下是非阻塞的,并且读取从不块),但任何一种都可以工作。

你提出了一个棘手的问题,一个没有真正好答案的问题。并发安全的数据结构很难编写,特别是当它们需要是可变的时。在共享状态存在的情况下,完全无锁的架构是不可能的,因此您可能希望放弃该要求。您可以做的最好是最小化所需的锁定,因此不可变的数据结构。

答案 1 :(得分:6)

链接列表绝对是答案。在O(1)中插入和删除,在O(1)中从一个节点到下一个节点的迭代以及跨操作的稳定性。 std::list保证所有这些,包括所有迭代器都是有效的,除非从列表中删除该元素(这包括指针和对元素的引用)。对于锁定,您可以将列表包装在锁定类中,或者您可以编写自己的列表类(在这种情况下,您将无法使用std::list支持基于节点的锁定 - 例如,您可以锁定当其他线程在不同区域执行操作时,列表的某些区域可供使用。您使用的很大程度上取决于您期望的并发访问类型 - 如果列表的不同部分上的多个操作真的很常见,请编写自己的,但请记住,您将在每个节点中放置一个互斥对象,这不是节省空间的。

答案 2 :(得分:4)

道歉双重答案......

由于写入相当罕见,因此确实应考虑使用STM而不是锁定。 STM是乐观锁定的一种形式,这意味着它在无碰撞系统中的性能严重偏差(比如更少的写入)。相比之下,悲观锁定(锁定 - 写入 - 解锁)针对碰撞繁重的系统进行了优化(比如很多写入)。 STM的唯一问题是它几乎要求你在TVar单元中使用不可变数据结构,否则整个系统就会崩溃。就个人而言,我不认为这是一个问题,因为一个体面的不可变数据结构将像一个可变的数据结构一样快(参见我的其他答案),但值得考虑。

答案 3 :(得分:1)

我认为链表应该符合您的要求。请注意,您只能锁定正在更改的节点(即删除/附加),因此读者大多数时间都能够与编写器完全并行工作。 这种方法需要锁定每个链表节点,但这不是必须的。您可以使用有限数量的锁,然后将多个节点映射到同一个锁。即,具有N个锁的数组和编号为0..M的节点,您可以使用锁(NodeId%N)来锁定该节点。这些可以是读写锁,通过控制锁的数量,您可以控制并行数量。

答案 4 :(得分:1)

如果您不需要排序顺序,请不要使用红色/黑色树或其他任何固有排序的树。

对于读写之间的交互,你的问题没有明确说明。 如果通过锁定+复制+解锁然后使用新副本实现“读取”是否可以?

您可能希望阅读http://en.wikipedia.org/wiki/Seqlock中的seqlocks和一般的“无锁”进程 - 但是,您可能希望尽可能地放宽您的要求 - 无锁哈希表实现是一项重大任务。

答案 5 :(得分:1)

您有3种类型的任务:

  1. 迭代(慢)
  2. 插入(快速)
  3. 删除(快速)
  4. 如果接近一致性足够好,那么跟踪活动迭代任务的数量。

    如果迭代任务处于活动状态并且新的插入或删除任务排在队列中,那么这些任务将在稍后处理(但您可以立即返回给调用者)

    如果完成进程排队的最后一次迭代,则插入和删除。

    如果在插入或删除处于挂起状态时进入迭代请求,则将其排队。

    如果迭代请求进入时只有迭代运行,那就让它去迭代。

    如果实际数据处理比迭代本身花费的时间多得多,那么通过复制正在迭代的数据,然后在客户端处理该数据,您仍应尽可能快地编写迭代。 / p>

    我会使用哈希表或stl实现主集合:map甚至可能足够快。插入/删除请求可以在列表中排队。

答案 6 :(得分:1)

我认为这是可以实现的唯一方法是通过与oracle / postgresql等数据库中使用的多版本并发协议类似的东西。这可以保证读者不会阻止读者,编写者不会阻止读者,但是编写者只会阻止那些更新同一条数据的作者。在并发编程世界中,阻止更新同一数据的编写器的写入器的这种属性是重要的,否则数据/系统不一致是可能的。对于数据结构的每个写操作,您可以在执行写操作之前获取数据结构的快照,或者至少将受写操作影响的数据结构节点部分写入内存中的其他位置。因此,当写入正在进行时,读取器线程请求从写入器部分读取一部分数据,您总是参考最新的快照&迭代该快照,通过向所有读者提供一致的数据视图。快照是昂贵的,因为它们消耗更多的内存,但是对于您的给定要求是肯定的,这种技术是正确的。是的,使用锁(互斥/信号量/自旋锁)来保护写操作免受需要更新同一条数据的其他编写器线程/进程的影响。

答案 7 :(得分:1)

我不确定是否有人提到这一点,但我会从Java的ConcurrentHashMap中获取灵感。它提供遍历,检索和插入,无需锁定或等待。一旦找到与散列键对应的数据桶并且您正在遍历该存储桶(即,您只能锁定存储桶而不是实际的散列映射),就会发生唯一的锁定。 “ConcurrentHashMap使用固定的锁池来代替单个集合锁,这些锁形成了桶集合的分区。”

您可以找到有关实际实施的更多详细信息here。我相信实现中显示的所有内容都可以用C ++轻松完成。

让我们来看看你的要求清单:

1. High throughput. CHECK
2. Thread safe. CHECK
3. Efficient inserts happen in O(1). CHECK
4. Efficient removal (with no data races or locks). CHECK
5. VERY efficient traversal. CHECK
6. Does not lock or wait. CHECK
7. Easy on the memory. CHECK
8. It is scalable (just increase the lock pool). CHECK

以下是地图条目的示例:

protected static class Entry implements Map.Entry {
    protected final Object key;
    protected volatile Object value;
    protected final int hash;
    protected final Entry next;
    ...
}

请注意,该值是volatile,因此当我们删除一个Entry时,我们将值设置为NULL,这对于尝试读取该值的任何其他线程都是自动可见的。

答案 8 :(得分:0)

好吧,为了线程安全,你将不得不在某个时候锁定某些东西。一个关键的事情是确保存储库中的对象可以与存储库结构本身分开锁定:即,在您存储的数据中没有_next链接或任何类型的链接。这种方式读取操作可以锁定对象的内容,而无需锁定存储库的结构。

高效的插入很容易:链表,未排序的数组,哈希表都可以正常工作。有效删除更难,因为这涉及在存储库中查找已删除的内容。 Howerver,原始的简单性和速度,链表是一个不错的选择。是否可以删除非繁忙时间和仅标记为“非活动”的项目?然后查找/删除的成本不是那么限制。

你仍然会遇到遍历问题。您可以做的就是锁定并拍摄需要遍历的内容的快照,然后在查看快照后检查是否有任何更改。艰难的问题......

答案 9 :(得分:0)

FWIW,如果您有垃圾收集器,这很容易解决。例如,在F#中,您可以使用对链表或纯函数映射(平衡二叉树)的可变引用,而无需任何锁定。这是有效的,因为数据结构是不可变的,并且编写引用(在写入后更新)是原子的,因此并发读者可以保证看到旧的或新的数据结构但从不损坏。如果你有多个编写器,那么你可以序列化它们。

然而,这在C ++中难以解决......

答案 10 :(得分:-1)

我参加派对有点晚了。但是,如果某人仍在寻找这个问题的实际解决方案并且他们尚未确定服务器,请让我建议Google's App Engine。他们的数据存储区针对这些类型的要求进行了优化。