【问题标题】:Synchronizing access to a doubly-linked list同步访问双向链表
【发布时间】:2010-12-01 01:04:05
【问题描述】:

我试图在 C 中实现一个(特殊类型的)双向链表,在 pthreads 环境中,但仅使用 C 包装的同步指令,如原子 CAS 等,而不是 pthread 原语。 (列表的元素是固定大小的内存块,几乎肯定不能在其中容纳pthread_mutex_t 等。)我实际上并不需要完整的任意双向链表方法,只需:

  • 在列表末尾插入
  • 从列表开头删除
  • 根据指向要删除的成员的指针在列表中的任意点删除,该成员是从不是通过遍历列表的来源获得的。

因此,描述这种数据结构的更好方法可能是队列/先进先出,可以删除队列中的项目。

是否有标准的方法来同步?我陷入了可能的死锁问题,其中一些可能是所涉及的算法所固有的,而另一些可能源于我试图在受限空间中工作的事实,而其他限制我能做什么。

编辑:特别是,如果要同时移除相邻对象,我会卡在该怎么做。大概在删除一个对象时,您需要获取列表中前一个和下一个对象的锁,并更新它们的下一个/上一个指针以指向另一个。但是如果任一邻居已经被锁定,这将导致死锁。我试图找出一种方法,使发生的任何/所有删除都可以遍历列表的锁定部分并确定当前正在删除的最大子列表,然后锁定与该子列表相邻的节点,以便整个子列表被整体删除,但我的头开始受伤了.. :-P

结论(?):接下来,我确实有一些我想开始工作的代码,但我也对理论问题感兴趣。每个人的答案都非常有帮助,并结合了我在这里表达的约束之外的细节(你真的不想知道要删除的指向元素的指针来自哪里以及那里涉及的同步!)我决定暂时放弃本地锁定代码并专注于:

  • 使用大量较小的列表,每个列表都有单独的锁。
  • 在获取锁之前尽量减少持有锁和在内存中戳的指令数量(以安全的方式),以减少在持有锁时出现页面错误和缓存未命中的可能性。
  • 测量人为高负载下的争用并评估此方法是否令人满意。

再次感谢所有给出答案的人。如果我的实验不顺利,我可能会回到概述的方法(尤其是 Vlad 的方法)并重试。

【问题讨论】:

  • 我相信只要不关心列表的一致性,用双链表就可以做到。添加或删除的对象可能可见或不可见,具体取决于您在列表中移动的方向。
  • @R:固定长度的缓冲区(循环使用)对你没有好处?
  • 可悲的是,我找不到让它与固定长度的循环缓冲区一起工作的方法。我从一开始就想这样做,因为它会更简单,性能也会更好......
  • @R: 你的意思是你的意思是你找不到一种方法来让一个无锁的固定长度循环缓冲区工作,还是你找不到一种方法来让这样一个缓冲区满足您的应用程序的需求?
  • 我找不到让它满足要求的方法。我需要一种方法来提前确保循环缓冲区已经被充分扩大,以满足所有可能的未来插入,直到下一个点内存重新分配成为可能(在插入点是不可能的)。

标签: c synchronization pthreads linked-list


【解决方案1】:

为什么不直接应用粗粒度锁呢?只需锁定整个队列即可。

更复杂(但不一定更有效,取决于您的使用模式)解决方案是使用读写锁,分别用于读取和写入。


在我看来,对于您的情况,使用无锁操作不是一个好主意。想象一下,某个线程正在遍历您的队列,同时删除了“当前”项。不管你的遍历算法有多少额外的链接,所有的项目都可能被删除,所以你的代码将没有机会完成遍历。


比较和交换的另一个问题是,对于指针,您永远不知道它是否真的指向同一个旧结构,或者旧结构已被释放并且一些新结构被分配到同一个地址。这对您的算法来说可能是也可能不是问题。


对于“本地”锁定的情况(即单独锁定每个列表项的可能性),一个想法是使锁定有序。对锁进行排序可确保不会出现死锁。所以你的操作是这样的:

通过指向上一个项的指针p删除:

  1. 锁定 p,检查(可能使用项目中的特殊标志)项目是否仍在列表中
  2. lock p->下一步,检查它不为零并且在列表中;这样可以确保 p->next->next 在此期间不会被删除
  3. 锁定 p->下一个->下一个
  4. 在 p->next 中设置一个标志,表示它不在列表中
  5. (p->next->next->prev, p->next->prev) = (p, null); (p->next, p->next->next) = (p->next->next, null)
  6. 释放锁

插入开头:

  1. 锁头
  2. 在新项目中设置标志,表明它在列表中
  3. 锁定新项目
  4. 锁头->下一步
  5. (head->next->prev, new->prev) = (new, head); (new->next, head) = (head, new)
  6. 释放锁

这似乎是正确的,但是我没有尝试这个想法。

本质上,这使得双链表像单链表一样工作。


如果您没有指向前一个列表元素的指针(当然通常是这种情况,因为几乎不可能将这样的指针保持在一致状态),您可以执行以下操作:

通过指向要删除的项的指针c删除:

  1. 锁c,检查它是否仍然是列表的一部分(这必须是列表项中的标志),如果不是,操作失败
  2. 获取指针 p = c->prev
  3. 解锁 c(现在,c 可能被其他线程移动或删除,p 也可能从列表中移动或删除)[为了避免 c 的释放,您需要有类似共享指针或 at这里至少有一种对列表项的引用]
  4. 锁p
  5. 检查 p 是否是列表的一部分(它可以在第 3 步之后删除);如果没有,解锁 p 并从头开始
  6. 检查p->next是否等于c,如果不是,解锁p并从头开始[这里我们也许可以优化重启,不确定ATM]
  7. 锁定 p->下一步;在这里你可以确定 p->next==c 并没有被删除,因为删除 c 需要锁定 p
  8. 锁定 p->下一个->下一个;现在所有的锁都被占用了,所以我们可以继续了
  9. 设置 c 不是列表的一部分的标志
  10. 执行惯用的 (p->next, c->next, c->prev, c->next->prev) = (c->next, null, null, p)
  11. 释放所有锁

请注意,仅仅拥有一个指向某个列表项的指针并不能确保该项目不会被释放,因此您需要进行一种引用计数,以便在您尝试锁定它的那一刻该项目不会被破坏.


请注意,在最后一个算法中,重试次数是有限的。实际上,新项目不能出现在 c 的左侧(插入在最右边的位置)。如果我们的步骤 5 失败,因此我们需要重试,这只能由同时从列表中删除 p 引起。这样的删除可以发生不超过 N-1 次,其中 N 是 c 在列表中的初始位置。当然,这种最坏的情况不太可能发生。

【讨论】:

  • “遍历列表”不是我的链表上的操作。唯一的选择是在一端插入,从另一端移除,以及基于指向从另一个源(而不是遍历列表)获得的元素的指针移除。
  • 粗粒度锁?这将意味着在高负载下的近乎恒定的争用,而不是基本上为零的争用。无锁操作?抱歉,我不清楚;交换/CAS/等。如果它工作得更好,可以简单地用于实现小型自旋锁而不是无锁。 CAS ABA 问题 - 可能与这里无关,但我需要确定。
  • Re: 争用 - 你希望有多少线程进入这个列表?取消链接操作(毕竟只有少数指令)真的会花费这么多时间(与线程正在执行的其他工作相比,在锁之外)争夺列表范围的锁会成为问题吗?
  • @Vlad:谢谢。第二个可能的变体。一个问题是,在此开始之前,我在当前节点(要删除的节点)上有一个锁,由于其他原因我无法释放它。问题似乎是我试图将同一个锁用于两件不同的事情。如果我为作为链表成员的节点创建一个单独的锁定位(是的,遗憾的是我只有位空间..),并指定现有锁定仅适用于节点的其他现有角色,我认为你的算法可以工作。
  • @R..:请注意,我的实现满足@Adam 的想法。锁排序是避免死锁的好工具。
【解决方案2】:

请不要粗暴地接受这个答案,但不要这样做。

您几乎肯定会遇到错误,而且很难找到错误。使用 pthreads 锁定原语。它们是您的朋友,并且是由深入了解您选择的处理器提供的内存模型的人编写的。如果你尝试用 CAS 和原子增量等做同样的事情,你几乎肯定会犯一些微妙的错误,直到为时已晚。

这里有一个小代码示例来帮助说明这一点。这个锁怎么了?

volatile int lockTaken = 0;

void EnterSpinLock() {
  while (!__sync_bool_compare_and_swap(&lockTaken, 0, 1) { /* wait */ }
}

void LeaveSpinLock() {
  lockTaken = 0;
}

答案是:释放锁时没有内存屏障,这意味着在锁内执行的一些写操作可能在下一个线程进入锁之前没有发生。哎呀! (可能还有更多的错误,例如,该函数没有在自旋循环中执行适合平台的产量,因此非常浪费 CPU 周期。&c。)

如果您将双链表实现为带有前哨节点的循环列表,那么您只需执行两次指针分配即可从列表中删除项目,并执行四次添加项目。我相信你可以负担得起对这些指针分配持有一个写得很好的排他锁。

请注意,我假设您不是少数深入了解记忆模型的人之一,因为世界上很少有记忆模型。如果您是这些人中的一员,即使您无法弄清楚这一事实也应该表明它是多么棘手。 :)

我还假设您问这个问题是因为您有一些您实际上想要开始工作的代码。如果这只是一个学术练习,以了解更多关于线程的知识(也许是你成为深度低级并发专家的一步),那么无论如何,请忽略我,并研究内存的细节您定位的平台的型号。 :)

【讨论】:

  • +1 因为有了这样的答案,您不应该只有 1 个代表。 :-)
【解决方案3】:

如果您保持严格的锁层次结构,您可以避免死锁:如果您要锁定多个节点,请始终首先锁定靠近列表头部的节点。所以,要删除一个元素,先锁定节点的前驱,然后锁定节点,再锁定节点的后继,取消链接,然后以相反的顺序释放锁。

这样,如果多个线程尝试同时删除相邻节点(例如,链 A-B-C-D 中的节点 B 和 C),那么首先获得节点 B 锁定的线程将是第一个取消链接的线程。线程 1 将锁定 A,然后 B,然后 C,线程 2 将锁定 B,然后 C,然后 D。只有 B 的竞争,线程 1 无法在等待线程持有的锁时持有锁2 而线程 2 正在等待线程 1 持有的锁(即死锁)。

【讨论】:

  • 我喜欢这个。这很简单,我认为它符合要求。我确实必须为 node-as-member-of-list 做一个二级锁(因为我在其他 cmets 中描述过,当前节点的现有锁已经被持有并且不能在删除时释放),但是否则,实现这一点似乎很简单。
  • 问题是这样的:给定节点C,如何在不先锁定C的情况下获取并锁定B?在阅读 C.prev 之后,其他一些线程可能会更改它并删除 B,然后你就有一个悬空指针。
【解决方案4】:

如果不锁定整个列表,您将无法逃脱。原因如下:

插入空列表

线程 A 和 B 想要插入一个对象。

线程 A 检查列表,发现它是空的

发生上下文切换。

线程 B 检查列表,发现它为空并更新头部和尾部以指向其对象。

发生上下文切换

线程 A 更新头部和尾部以指向它的对象。线程 B 的对象已丢失。

从列表中间删除一个项目

线程 A 想要删除节点 X。为此它首先必须锁定 X 的前任、X 本身和 X 的后继,因为所有这些节点都会受到操作的影响。要锁定 X 的前任,您必须执行类似

的操作
spin_lock(&(X->prev->lockFlag));

虽然我使用了函数调用语法,但如果spin_lock 是一个函数,那你就死定了,因为在你真正拥有锁之前至少涉及三个操作:

  • 将锁定标志的地址放在堆栈中(或寄存器中)
  • 调用函数
  • 进行原子测试并设置

那里有两个地方可以换出线程 A,另一个线程可以进入并删除 X 的前任,而线程 A 不知道 X 的前任已更改。所以你必须以原子方式实现自旋锁本身。即您必须向 X 添加一个偏移量以获取 x->prev 然后取消引用它以获取 *(x->prev) 并为其添加一个偏移量以获取 lockFlag 然后在一个原子单元中执行原子操作。否则,在您承诺锁定特定节点之后但在您实际锁定它之前,总会有一些东西潜入。

【讨论】:

  • @JeremyP: re: 插入一个空列表。线程 A 必须首先锁定头部,然后对其进行检查和更新,然后才解锁。这样我们就没有你描述的问题了。
  • @Vlad:确实。或者干脆把头尾永久节点(可能是同一个节点)去掉特殊套管。
  • @R..:确实,头部和尾部必须是永久性的。但是,我会避免让它们使用相同的节点,因为锁定头部会隐式锁定尾部,所以这会破坏锁定顺序。 (考虑只有头部、尾部、两个项目和两个线程试图同时删除这些项目的情况。)
  • @R.. @Vlad:如果您将头部和尾部设为永久节点并且始终必须锁定它们,那么您将创建一个列表范围的锁定 - 以及两个。而且您仍然没有解决删除时的竞争条件。
  • 我不确定,我得考虑一下。
【解决方案5】:

我注意到你在这里需要一个双向链表的唯一原因是因为需要从列表中间删除节点,这些节点是在不遍历列表的情况下获得的。一个简单的 FIFO 显然可以用一个单链表(头指针和尾指针)来实现。

您可以通过引入另一层间接来避免从中间删除的情况 - 如果列表节点只包含一个 next 指针和一个 payload 指针,而实际数据指向其他地方(你说内存分配在插入点是不可能的,因此您只需要在分配有效负载本身的同一点分配列表节点结构。

在从中间删除的情况下,您只需将payload 指针设置为NULL 并将孤立节点留在列表中。如果 FIFO 弹出操作遇到这样一个空节点,它只是将其释放并重试。这种延迟让您可以使用单链表,并且无锁单链表实现更容易实现。

当然,围绕移除队列中间的一个节点,这里仍然存在一场重要的竞赛——似乎没有什么可以阻止该节点来到队列的前面,并在该线程之前被另一个线程移除决定要删除它实际上有机会这样做。这场比赛似乎超出了您问题中提供的详细信息的范围。

【讨论】:

    【解决方案6】:

    两个想法。

    首先,为了避免死锁问题,我会做一些自旋锁:

    • 锁定要删除的项目
    • 尝试锁定其中一个邻居,如果您有便宜的随机位可用,请随机选择一侧
    • 如果这不成功放弃你的第一个锁 并循环
    • 尝试锁定另一个
    • 如果成功删除您的项目
    • 否则放弃两个锁 并循环

    由于从列表中拼接一个元素作为一个操作并不是很冗长,因此这不会花费您太多的性能开销。如果你真的急于同时删除所有元素,它仍然应该给你一些很好的并行性。

    第二个是懒惰删除。标记要删除的元素,并且仅当它们出现在列表末尾时才有效地删除它们。由于您只对头部和尾部感兴趣,因此列表项的有效用户可以执行此操作。这样做的好处是当它们在最后被删除时,死锁问题就消失了。缺点是这使得最终的删除成为一个顺序操作。

    【讨论】:

    • 无法进行延迟删除;这会导致内存损坏,因为一旦从列表中拉出节点,存储 prev/next 指针的内存就会被提交给不同的用途。
    • 如果你放弃你的第一个锁,然后其他线程删除了该项目怎么办。然后你有一个指向该项目的悬空指针并试图锁定它。
    猜你喜欢
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 2018-03-28
    • 1970-01-01
    • 1970-01-01
    • 2015-10-12
    相关资源
    最近更新 更多