这种无锁的dlist插入安全吗?

时间:2019-01-02 09:56:29

标签: c++ concurrency linked-list atomic lock-free

我需要在双向链接列表的开头实现无锁子列表的插入。该列表有一个虚拟头,因此每个线程都尝试在头节点之后插入其部分。这种设计对我来说似乎还可以,但是,我没有足够的专业知识来证明它。

struct Node {
  std::atomic<Node*> next;
  std::atomic<Node*> prev;
};
Node head;

// concurrently insert `first`..`last` sublist after `head`
void insertSublist(Node* first, Node* last) {
  first->prev = &head;
  Node* current_next = head.next;

  while (true) {
    last->next = current_next;
    if (head.next.compare_exchange_weak(current_next, first)) {
      current_next->prev = last;
      return;
    }
  }
}

在以下情况下,我需要验证此设计:

变种1

不执行列表删除操作,所有线程仅在循环中插入。

变种2

有1个线程,以随机顺序从列表中删除节点,但是永远不会删除紧接在头节点之后的节点。

for (auto* node : nodes_to_be_removed) {
  if (node->prev == &head)
    continue;
  // perform removal 
}

插入完成后,node->prev是最后一个更改的链接。因此,在更改之后,没有其他线程(除去程序)可以访问该节点或其先前的节点next链接。 这种推理有效吗?或者我缺少什么?


@ peter-cordes回答后有一些澄清。

  • 列表不是线性可遍历的,因此从这个角度来看,不一致的列表状态不是问题。
  •   

    如果您删除了插入程序将要修改(以添加向后链接)但尚未插入的节点

    我希望支票node->prev == &head会阻止这种情况。 是真的吗?

  • 在这些条件下清除是否安全?
    • 只有1个卸妆线程
    • 删除器具有一个单独的要删除节点的工作清单
    • 一个节点只有在其插入阶段完全完成之后才能添加到工作列表中

1 个答案:

答案 0 :(得分:4)

TL:DR :仅取决于读者的操作(没有长期损坏),单独插入是可以的,但是如果没有锁定或更加复杂,则删除可能是不可能的,并且绝对是最畅销的这个简单的插入算法。


这是一个双向链接的列表,因此,插入不可避免地需要修改其他线程已经可以看到的两个内存位置:head.next.prev指针,除非硬件具有DCAS (double-CAS, two separate non-contiguous locations at once),否则无法原子+无锁地完成此操作。如Wikipedia文章所述,它使无锁的双向链接列表变得容易。

m68k在某一点上具有DCAS,但是目前没有主流的CPU体系结构。 ISO C ++ 11不会通过std::atomic公开DCAS操作,因为您不能在没有使所有atomic<T>成为非锁定状态的硬件上模拟DCAS操作。除了具有事务性内存的硬件外,英特尔(例如Broadwell和更高版本)在某些最近的x86 CPU上可用,但AMD不可用。在将TM的语法添加到C ++方面已有一些工作,请参见https://en.cppreference.com/w/cpp/language/transactional_memory


当然,对于观察者而言,如果没有事务性内存或DCAS之类的东西,一次原子地观察两个位置也是不可能的。因此,任何读取列表的线程都希望它会发生变化从它们下面移开,尤其是如果该列表还应该支持删除操作。

在发布之前在新节点(尚未发布到其他线程)中设置指针显然是一件好事,而您正在这样做。在CAS尝试发布这些新节点之前,first->prevlast->next均已正确设置。 CAS具有顺序一致性内存排序,因此可以确保以前的存储在其他线程之前是可见的。 (因此,出于效率考虑,最好将这些“私有”存储区设置为std :: memory_order_relaxed。)

在修改.prev之后,您选择修改旧优先的head指针 的选择很有意义。实际上,您首先要在正向发布,然后在反向发布。 但是请记住,线程可能在任何点上长时间睡眠,因此假设这始终是暂时的不一致并不是100%安全的。想象一下停止调试器中的一个线程在此函数内的任何位置,甚至单步执行,而其他线程在运行。在这种情况下,只有两个有趣的操作:CAS和无条件存储到旧的第一个非虚拟节点。

如果线程正在向前移动,并且取决于能够通过遵循.prev来返回(而不是在局部变量中记住它自己的前一个),则它看起来像是删除了新节点再次。它可以找到指向.prev的{​​{1}}。这是一个人为的示例,因为如果您想再次找到它,通常记住一个上一个节点通常会更有效,尤其是在无锁列表中。但是,也许存在一些非人为的情况,例如,一个线程向前移动而另一个线程向后移动,并且可能直接或间接地进行交互,从而可以看到不一致的地方。


只要所有线程都同意修改顺序,我认为插入本身是安全的。仅在头部执行此操作就更易于验证,但我认为允许任意插入点仍然是安全的。

您当前的代码对于同时插入(假设没有删除)看起来是安全的。前向列表可以比后向列表长(可能有多个插入未完成的向后列表),但是一旦它们全部完成,列表将保持一致。

在不删除的情况下,对head的每个未决写入都具有有效的目的地,并且该目的地是其他线程都不想写入的节点。 无锁单链接列表的插入很容易,并且转发列表(.prev链接)始终是最新的。

因此,当每个插入操作在.next中的存储都可见时,它会将其用作插入点的节点“声明”到反向列表中。


current_next->prev循环是一个很好的习惯用法,通常可以简化代码。我削弱了其他操作的内存顺序,尤其是在第一个操作和最后一个操作中,由于要求编译器在存储到其他线程看不到的元素之后使用慢速屏障效率低下,因此降低了操作效率。在x86上,发布存储区是“免费的”(没有额外的障碍),而seq-cst存储区则损失更大。 (在无竞争的情况下,x86上的seq-cst存储的成本与原子级的read-modify-write大致相同。)

do{}while(!CAS())

这将使用零个// no change in logic to yours, just minimize memory ordering // and simplify the loop structure. void insertSublist(Node* first, Node* last) { first->prev.store(&head, std::memory_order_relaxed); Node* current_next = head.next.load(std::memory_order_relaxed); do { // current_next set ahead of first iter, and updated on CAS failure last->next.store(current_next, std::memory_order_relaxed); }while (!head.next.compare_exchange_weak(current_next, first)); // acq_rel CAS should work, but leave it as seq_cst just to be sure. No slower on x86 current_next->prev.store(last, std::memory_order_release); // not visible before CAS } 指令(而不是您的on the Godbolt compiler explorer的3个指令)为x86进行编译。 (组件的其余部分实际上是相同的,包括一个mfence。)因此,在无竞争的无RFO的情况下(例如,从同一内核重复插入),速度可能快4倍。或更好,因为lock cmpxchg实际上比Intel CPU上的mfence前缀还要慢。

另外,lock的最终存储完全位于循环之外,对于人类而言,可以说更容易立即读取和查看逻辑。


撤除是一个巨大的并发症

当您有待处理的插入内容时,我看不到如何安全删除。如果您删除了插入程序将要修改的节点(以添加向后链接),但尚未删除,则该节点范围将永远从反向列表中丢失。

(此外,如果您回收该节点的内存,则由插入程序存储的内容将继续执行操作。)

这将使前进和后退列表不同步。 如果没有DCAS(或事务性内存,这是DCAS的超集),我看不到解决此问题的方法。不过,我不是无锁的dlist专家,所以也许有个窍门。

可能甚至多个同时的卸妆也是一个问题,因为您可能最终要对另一个线程即将(或已经被)移除的节点进行修改。或对一个节点的多个待处理的修改,无法确保正确的修改最后完成。

如果您具有插入器/删除器锁(多个插入器/单个删除器,与读取器/写入器锁完全一样),则可以确保进行删除时没有待处理的插入。允许无锁插入。也许将锁与do{}while(!CAS)放在同一缓存行中,因为插入线程将始终需要同时修改它和head。也许不是,因为如果核心在获得锁定之后但在将其修改为head之前有时失去了对该行的所有权,那么您可能最终会对该行产生更多争用。