有趣的是,我发现许多程序员错误地认为"无锁定"只是意味着"并发编程没有互斥体"。通常,还存在一个相关的误解,即编写无锁代码的目的是为了获得更好的并发性能。当然,无锁的正确定义实际上是关于进度保证。无锁算法保证至少一个线程能够前进,无论其他线程正在做什么。
这意味着无锁算法永远不会有代码,其中一个线程依赖于另一个线程才能继续。例如,无锁代码不能具有线程A设置标志的情况,然后线程B在等待线程A取消设置标志时保持循环。像这样的代码基本上实现了一个锁(或者我称之为伪装的互斥锁)。
然而,其他情况更为微妙,在某些情况下我真的无法确定算法是否符合无锁定的要求,因为"取得进展的概念"有时对我来说似乎是主观的。
一个这样的案例出现在(备受好评的,afaik)并发库liblfds中。我正在研究liblfds中多生产者/多消费者有界队列的实现 - 实现非常简单,但我无法确定它是否应该被认定为无锁。
相关算法位于lfds711_queue_bmm_enqueue.c
。 Liblfds使用自定义原子和内存障碍,但算法很简单,我可以用段落左右来描述。
队列本身是一个有界的连续数组(ringbuffer)。共享read_index
和write_index
。队列中的每个插槽都包含一个用户数据字段和一个sequence_number
值,它基本上类似于一个纪元计数器。 (这避免了ABA问题。)
PUSH算法如下:
write_index
write_index % queue_size
设置为write_index
的CompareAndSwap循环在write_index + 1
处保留队列中的插槽。 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
等于{{1}之前将无法这样做}}
好的,所以这里没有互斥,算法可能表现良好(它只是PUSH和POP的一个CAS),但这是否无锁?我不清楚的原因是因为&#34;取得进展的定义&#34;如果观察到队列已满或空,则PUSH或POP可能总是失败,这似乎是模糊的。
但对我来说值得怀疑的是,PUSH算法基本上保留一个插槽,这意味着插槽永远不会是POP,直到推送线程绕过更新序列号。这意味着想要弹出值的POP线程取决于已完成操作的PUSH线程上的。否则,POP线程将始终返回read_index + 1
,因为它认为队列是EMPTY。对我而言,这实际上是否属于&#34;取得进展&#34;。
通常,真正无锁的算法涉及一个阶段,其中抢占线程实际上试图在完成操作时辅助另一个线程。因此,为了真正无锁,我认为观察正在进行的PUSH的POP线程实际上需要尝试并完成PUSH,然后才能执行原始的POP操作。如果POP线程在PUSH正在进行时简单地返回队列为EMPTY,则POP线程基本上被阻止,直到PUSH线程完成操作。如果PUSH线程死亡,或进入休眠状态1000年,或以其他方式被安排被遗忘,POP线程除了连续报告队列是EMPTY之外什么都不做。
那么这适合无锁的定义吗?从一个角度来看,你可以说POP线程总能取得进展,因为它总能报告队列是EMPTY(这至少是我猜的某种形式的进展。)但对我来说,这并不是真的。取得进展,因为队列被视为空的唯一原因是因为我们被并发PUSH操作阻止。
所以,我的问题是:这个算法真的无锁吗?或者索引预订系统基本上是伪装的互斥体?
答案 0 :(得分:10)
根据我认为最合理的定义,此队列数据结构不严格无锁。这个定义类似于:
只有当任何线程可以无限期时,结构才是无锁的 在任何一点悬挂,同时仍然留下可用的结构 剩余的线程。
当然这意味着可用的合适定义,但对于大多数结构而言,这很简单:结构应该继续遵守其合同,并允许按预期插入和删除元素。
在这种情况下,成功递增m_write_increment
但尚未写入s.sequence_number
的线程将容器置于即将成为不可用状态的容器中。如果这样的线程被杀死,容器最终将报告&#34; full&#34;和&#34;空&#34;分别为push
和pop
,违反了固定大小队列的合同。
这里是一个隐藏的互斥锁(m_write_index
和关联的s.sequence_number
的组合) - 但它基本上像每个元素的互斥锁一样工作。因此,一旦你循环并且新作者试图获取互斥锁,失败只会变成明显的,但实际上所有后续的编写者实际上都失败了将他们的元素插入队列,因为没有读者会看到它。
现在这并不意味着这是并发队列的错误实现。对于某些用途,它可能主要表现为无锁。例如,此结构可能具有真正无锁结构的大多数有用的性能属性,但同时它缺少一些有用的正确性属性。基本上,术语无锁通常意味着一大堆属性,其中只有一部分通常对任何特定用途都很重要。让我们逐个看一下它们,看看这个结构是如何做的。我们将它们广泛地分类为性能和功能类别。
无竞争或最佳情况&#34;性能对许多结构都很重要。虽然您需要并发结构以确保正确性,但您通常仍会尝试设计应用程序,以便将争用保持在最低限度,因此无争用成本通常很重要。一些无锁结构通过减少无竞争快速路径中昂贵的原子操作数量或避免syscall
来帮助实现。
这个队列实现在这里做了一个合理的工作:只有一个&#34;绝对昂贵&#34;操作:compare_exchange_weak
,以及一些可能很昂贵的操作(memory_order_acquire
加载和memory_order_release
存储) 1 ,以及其他一些开销。
这比较类似于std::mutex
,这意味着像锁定的一个原子操作和解锁的另一个原则操作,而在Linux上实际上,pthread调用也具有不可忽略的开销。
所以我希望这个队列在无竞争的快速路径中表现得相当好。
无锁结构的一个优点是,当结构严重争用时,它们通常允许更好的缩放。这不一定是固有的优势:一些具有多个锁或读写锁的基于锁的结构可能表现出与某些无锁方法相匹配或超过某些无锁方法的扩展,但通常情况就是如此无锁结构表现出更好的缩放比例,这是一种简单的单锁定规则 - 所有替代方案。
此队列在这方面表现合理。 m_write_index
变量由所有读者自动更新,并且将成为争用点,但只要底层硬件CAS实现合理,行为应该是合理的。
请注意,队列通常是一个相当差的并发结构,因为插入和删除都发生在相同的位置(头部和尾部),因此争用是结构定义中固有的。将此与并发映射进行比较,其中不同的元素没有特定的有序关系:如果访问不同的元素,这样的结构可以提供有效的无争用同时突变。
与上面的核心定义(以及功能保证)相关的无锁结构的一个性能优势是,正在改变结构的线程的上下文切换并不会延迟所有其他的mutators 。在负载很重的系统中(特别是当可运行的线程&gt;&gt;可用内核时),线程可以被切换出数百毫秒或几秒。在此期间,任何并发的mutator都会阻塞并产生额外的调度成本(或者它们会旋转,这也可能导致不良行为)。即使这样&#34;不幸的安排&#34;可能很少见,当它确实发生时,整个系统可能会导致严重的延迟峰值。
无锁结构避免了这种情况,因为没有关键区域&#34;其中一个线程可以被上下文切换,然后阻止其他线程前进。
此结构在此区域中提供部分保护 - 其细节取决于队列大小和应用程序行为。即使在m_write_index
更新和序列号写入之间的关键区域中切换了一个线程,其他线程也可以继续push
个元素到队列,只要它们不包装所有从停滞的线程到进行中元素的方法。线程也可以是pop
元素,但仅限于进行中元素。
虽然push
行为可能不是高容量队列的问题,但pop
行为可能是一个问题:如果队列的吞吐量高于线程上下文的平均时间切换出来,即使在进行中元素之外添加了许多元素,队列也会很快显示为所有消费者线程的空白。这不受队列容量的影响,而只受应用程序行为的影响。这意味着当发生这种情况时,消费者方可能完全失速。在这方面,队列看起来根本没有锁定!
在无锁结构的优势下,它们可以安全地用于可能asynchronously canceled的线程,或者可能在关键区域中异常终止。在任何时候取消线程都会使结构处于一致状态。
如上所述,此队列不是这种情况。
相关的优点是通常可以从中断或信号中检查或改变无锁结构。在中断或信号与常规进程线程共享结构的许多情况下,这很有用。
此队列主要支持此用例。即使在另一个线程处于关键区域时发生信号或中断,异步代码仍然可以push
队列中的元素(以后只能通过消费线程看到),并且仍然可以pop
队列中的一个元素。
行为并不像真正的无锁结构一样完整:设想一个信号处理程序,它可以告诉剩余的应用程序线程(除了被中断的应用程序线程)静默,然后排出所有剩余的元素的队列。使用真正的无锁结构,这将允许信号处理程序完全耗尽所有元素,但是如果线程在关键区域中断或切换,则此队列可能无法执行此操作。
1 特别是在x86上,这只会对CAS使用原子操作,因为内存模型足够强大,可以避免对其他操作使用原子或栅栏。最近的ARM也可以相当有效地获取和发布。
答案 1 :(得分:4)
我是liblfds的作者。
OP对这个队列的描述是正确的。
它是库中的唯一数据结构,并非无锁。
这在队列的文档中有所描述;
“必须了解,尽管它实际上不是无锁的数据结构。”
这个队列是Dmitry Vyukov(1024cores.net)的一个想法的实现,我只是意识到在使测试代码正常工作时它不是无锁的。
那时它正在工作,所以我将其包括在内。
我确实有删除它的想法,因为它不是无锁的。
答案 2 :(得分:1)
如果POP调用立即返回FALSE,则在序列完成下一次更新之前调用POP的线程不会“有效阻止”。线程可以关闭并执行其他操作。我会说这个队列符合无锁状态。
但是,我不会说它有资格作为“队列” - 至少不是那种你可以作为库中的队列发布的队列 - 因为它不能保证很多您通常可以从队列中获得的行为。特别是,你可以PUSH和元素,然后尝试和FAIL来POP它,因为其他一些线程忙于推送早期的项目。
即便如此,对于各种问题,这个队列在一些无锁解决方案中仍然有用。
然而,对于许多应用程序,我担心在生产者线程被抢占时消费者线程可能会被饿死。也许liblfds对此有所帮助?
答案 3 :(得分:1)
“无锁”是算法的属性,它实现了一些功能。该属性与某种方式无关,即程序如何使用给出。
当谈到$times = array("9:00", "10:00", "11:00", "12:00", "13:00");
$dates = array(
"28/07/2018" => array("10:00", "11:00"),
"29/07/2018" => array("10:00", "13:00"),
"30/07/2018" => array("11:00", "13:00"));
$filtered = [];
foreach($dates as $day => $taken_times) {
$filtered[$day] = array_values(array_diff($times, $taken_times));
}
print_r($filtered);
函数时,如果底层队列已满,则返回FALSE,其实现(在问题帖子中给出)是无锁。
但是,以无锁方式实施mcmp_queue::enqueue
将很困难。例如,这种模式显然不是无锁定的,因为它在由其他线程改变的变量上旋转:
mcmp_queue::dequeue
答案 4 :(得分:1)
大部分时间人们使用无锁时他们真的意味着无锁。无锁意味着不使用锁的数据结构或算法,但无法保证前进。同时检查this question。所以liblfds中的队列是无锁的,但是BeeOnRope提到的并不是无锁的。
答案 5 :(得分:1)
几年前,我使用Spin进行了相同代码的形式验证,以进行并发测试课程,并且肯定不是无锁的。
仅仅因为没有显式的“锁定”,并不意味着它是无锁的。当谈到进度条件的推理时,请从单个线程的角度来思考:
阻止/锁定:如果调度了另一个线程,并且这可能阻止我的进度,则它正在阻止。
无锁/非阻塞:如果在没有其他线程争用的情况下最终能够取得进展,那么它最多是无锁的。
如果没有其他线程可以无限期地阻止我的进度,那么它是免费的。