一尘不染

无锁进度保证

algorithm

有趣的是,我发现许多程序员错误地认为“无锁”只是意味着“没有互斥量的并发编程”。通常,还有一个相关的误解,即编写无锁代码的目的是为了获得更好的并发性能。当然,无锁的正确定义实际上与
进度保证有关 。无锁算法可确保至少一个线程能够前进,而不管其他线程在做什么。

这意味着无锁算法永远不会拥有一个线程依赖另一个线程才能继续执行的代码。例如,无锁代码不会出现线程A设置标志,然后线程B在等待线程A取消设置标志的同时保持循环的情况。像这样的代码基本上实现了一个锁(或者我会变相地称呼互斥体)。

但是,其他情况则比较微妙,在某些情况下,老实说我无法真正判断算法是否符合无锁要求,因为“取得进步”的概念有时对我而言是主观的。

一种这样的情况在(广受好评的afaik)并发库 liblfds中
。我正在研究liblfds中多生产者/多消费者有界队列的实现-该实现非常简单,但是我无法真正确定它是否应视为无锁。

相关算法在中lfds711_queue_bmm_enqueue.c。Liblfds使用自定义原子和内存屏障,但是该算法非常简单,足以让我在一段左右的时间内进行描述。

队列本身是一个有界的连续数组(ringbuffer)。有一个共享read_indexwrite_index。队列中的每个插槽都包含一个用于用户数据的字段和一个sequence_number值,该值基本上类似于纪元计数器。(这避免了ABA问题)。

PUSH算法如下:

  1. 以原子方式加载 write_index
  2. 尝试write_index % queue_size使用CompareAndSwap循环尝试将其设置write_index为,以在队列中保留一个插槽write_index + 1
  3. 如果CompareAndSwap成功,则将用户数据复制到保留的插槽中。
  4. 最后,sequence_index通过使其等于来更新插槽上的write_index + 1

实际的源代码使用自定义原子和内存屏障,因此为了进一步了解该算法,我将其简短地转换为(未经测试的)标准C ++原子,以提高可读性,如下所示:

bool mcmp_queue::enqueue(void* data)
{
    int write_index = m_write_index.load(std::memory_order_relaxed);

    for (;;)
    {
        slot& s = m_slots[write_index % m_num_slots];
        int sequence_number = s.sequence_number.load(std::memory_order_acquire);
        int difference = sequence_number - write_index;

        if (difference == 0)
        {
            if (m_write_index.compare_exchange_weak(
                write_index,
                write_index + 1,
                std::memory_order_acq_rel
            ))
            {
                break;
            }
        }

        if (difference < 0) return false; // queue is full
    }

    // Copy user-data and update sequence number
    //
    s.user_data = data;
    s.sequence_number.store(write_index + 1, std::memory_order_release);
    return true;
}

现在,要从插槽中弹出元素的线程read_index将无法这样做,直到观察到插槽的sequence_number等于read_index + 1

好的,因此这里没有互斥体,并且该算法的性能可能不错(对于PUSH和POP来说只有一个CAS),但这是无锁的吗?我不清楚的原因是,如果有可能观察到队列已满或为空时,PUSH或POP总是会失败,那么“取得进展”的定义似乎就变得模糊了。

但是对我来说值得怀疑的是,PUSH算法本质上 保留
了一个插槽,这意味着在推送线程到来更新序列号之前,永远无法对插槽进行POP操作。这意味着要弹出值的POP线程 取决于
已完成操作的PUSH线程。否则,POP线程将始终返回,false因为它认为队列为EMPTY。对于我来说,这是否真正属于“取得进展”的定义似乎值得商de。

通常,真正的无锁算法涉及一个阶段,在该阶段,抢占线程实际上会尝试在完成操作时协助另一个线程。因此,为了真正实现无锁定,我认为观察到正在进行的PUSH的POP线程实际上需要尝试并完成PUSH,然后才执行原始的POP操作。如果在进行PUSH时POP线程简单地返回队列为EMPTY,则基本上
阻塞
POP线程,直到PUSH线程完成操作为止。如果PUSH线程死亡,进入睡眠状态1,000年或以其他方式被遗忘,则POP线程将无能为力,除非连续报告该队列为EMPTY。

那么这是否适合无锁的定义?从一个角度来看,您可以辩称POP线程总是可以取得进展,因为它可以始终报告队列为EMPTY(我猜这至少是某种形式的进展。)但是对我而言,这并不是真正的进展,因为观察到队列为空的唯一原因是因为我们被并发的PUSH操作
阻塞

所以, 我的问题是 :这个算法真的是无锁的吗?还是索引保留系统基本上是变相的互斥体?


阅读 234

收藏
2020-07-28

共1个答案

一尘不染

根据我认为最合理的定义,此队列数据结构 并非 严格无 。该定义类似于:

仅当任何线程可以在任意点无限期地挂起而其余结构仍可使用该结构时,该结构才是无锁的。

当然,这意味着一个合适的定义 可用 ,但对于大多数结构,这是相当简单的:结构应继续服从其合同和允许插入和如预期删除的元素。

在这种情况下,已成功递增m_write_increment但尚未写入的线程s.sequence_number将使容器处于不久将无法使用的状态。如果这样的线程被杀死,则容器最终将分别向push和报告“已满”和“空”
pop,这违反了固定大小队列的合同。

还有 就是 这里隐藏互斥(组合m_write_index和相关的s.sequence_number
-但它基本上就像每个元素互斥。因此,失败只有在您遍历并且新的作者尝试获取互斥体时才对编写者 显而易见 ,但是实际上, 所有 后续的编写者实际上
无法将其元素插入队列,因为没有读者会看到它。

现在,这并不意味着这不是并行队列的 错误 实现。对于某些用途,它的行为通常看起来像是无锁的。例如,该结构可能具有真正无锁结构的大多数
有用的性能属性 ,但同时却缺少一些 有用的正确性属性 。基本上,术语“无 锁”
通常意味着一整套属性,通常只有其中的一个子集对任何特定用途都很重要。让我们一个个看一下它们,看看这种结构如何工作。我们将它们大致分为性能和功能类别。

性能

无与伦比的表现

无竞争或“最佳情况”的性能对于许多结构都很重要。尽管您需要一个并发结构来确保正确性,但是您通常仍会尝试设计应用程序,以将争用保持在最低水平,因此无竞争的成本通常很重要。一些无锁结构通过减少无竞争的快速路径中昂贵的原子操作数或避免使用,在此提供了帮助syscall

此队列实现在这里做得很合理:只有一个“绝对昂贵”的操作:和compare_exchange_weak,以及几个可能昂贵的操作(memory_order_acquire装入和memory_order_release存储)1,其他开销很少。

与之相比,std::mutex这意味着一个原子操作用于锁定而另一个原子操作用于解锁,并且实际上在Linux上,pthread调用也具有不可忽略的开销。

因此,我希望该队列在无竞争的快速路径中能够表现良好。

竞争表现

无锁结构的一个优点是,当结构竞争激烈时,它们通常允许更好的缩放比例。这不一定是 固有的
优势:某些具有多个锁或读写锁的基于锁的结构可能会显示出与某些无锁方法相匹配或超出的缩放比例,但通常在这种情况下,无锁结构会显示出更好的缩放比例,一个简单的一锁到所有规则的选择。

在这方面,此队列可以合理执行。该m_write_index变量由所有读者自动更新,将成为争论的焦点,但是,只要基本的硬件CAS实现是合理的,该行为就应该是合理的。

请注意, 队列
通常是相当差的并发结构,因为插入和删除都发生在相同的位置(头和尾),因此争用是结构定义中固有的。将此与并发映射进行比较,在并发映射中,不同的元素没有特定的有序关系:如果访问不同的元素,则这种结构可以提供有效的无竞争的同时突变。

上下文切换免疫

与上面的核心定义(以及功能保证)相关的无锁结构的一个性能优势是,正在使结构发生变化的线程的上下文切换不会延迟所有其他的变化器。在负载很重的系统中(尤其是在可运行线程>>可用内核时),线程可能会中断数百毫秒或几秒钟。在这段时间内,任何并发的mutator都会阻塞并招致额外的调度成本(或者它们会旋转,这也可能产生不良行为)。即使这种“运气不好的调度”可能很少见,但当确实发生时,整个系统可能会引起严重的延迟尖峰。

无锁结构避免了这种情况,因为不存在可以将上下文上下文切换出线程并随后阻止其他线程继续前进的“关键区域”。

这种结构在此区域提供了 部分 保护-
具体取决于队列大小和应用程序行为。即使线程在m_write_index更新和序列号写入之间的关键区域中切换出来,其他线程也可以继续进入push队列,只要它们不会从停顿状态一直循环到
正在进行的 元素线。线程也可以是pop元素,但最多只能是进行 中的 元素。

尽管此push行为对于高容量队列而言可能不是问题,但该pop行为可能是一个问题:如果与上下文被线程切换的平均时间相比,该队列具有较高的吞吐量,并且该线程的平均填充度很快,则该队列将快速出现即使
在进行中
元素之外添加了许多元素,所有消费者线程也为空。这不受队列容量的影响,而仅受应用程序行为的影响。这意味着发生这种情况时,用户方可能会完全停滞。在这方面,队列看起来并不是完全没有锁的!

功能方面

异步线程终止

利用无锁结构的优势,它们可以安全地用于可能被异步取消或可能在关键区域异常终止的线程使用。在任何时候取消线程都会使结构处于一致状态。

如上所述,此队列不是这种情况。

来自中断或信号的队列访问

一个相关的优点是,通常可以从中断或信号中检查或更改无锁结构。这在中断或信号与常规进程线程共享结构的许多情况下很有用。

该队列主要支持此用例。即使在另一个线程处于关键区域时发生信号或中断,异步代码仍然可以push将元素放入队列(只有稍后使用线程才能看到),并且仍然可以pop使元素离开队列。

该行为不像真正的无锁结构那样完整:想象一个信号处理程序,该方法可以告诉其余应用程序线程(而不是被中断的线程)停顿,然后耗尽队列中的所有其余元素。使用真正的无锁结构,这将允许信号处理程序完全耗尽所有元素,但是如果线程在关键区域被中断或切换出该队列,则该队列可能无法执行此操作。


1特别是,在x86上,由于CAS模型的内存足够强大,可以避免对其他操作使用原子或栅栏,因此对CAS仅使用原子操作。最近的ARM也可以相当有效地进行获取和发布。

2020-07-28