文章目录
1. 无锁队列概述
本文原文地址: design/lockfree_queue.md
我们解释一些无锁队列的细节,以便提供对无锁队列工作原理的直观理解。如果需要,这可以作为以后正式证明的基础。
设计适用于任意类型的无锁队列的关键构建块是无锁索引队列,它只能存储整数值的索引。
我们简要描述一下索引队列的概念。
2. 索引队列
在下面的描述中,所有的数字都是自然数,因此在实现中是有界的无符号整数。
2.1 容量和周期长度
索引队列有一定的容量 n
,并在范围 [0, n[ 内存储索引。
数字 n
也称为周期长度。
在本说明中,假设 n = 4
,因此我们可以存储索引 0、1、2、3。
2.2 索引表示
每个索引 i
对应于模 n
的等价类 [i],即
[i] = {j | j = c*n + i, c >= 0}
这意味着 j = 3, 7, 11, ...
都表示相同的索引 3(指向数组中的最后一个元素)。
给定 j
,我们有 i = j % n
和 c = j / n
(其中 %
是取模,/
是整数除法)。
另一种唯一表示值 j
的方法是作为一对 (c,i)
,我们称之为具有周期 c
和索引 i
的周期索引。
这样做有两个原因:检测空队列和消除所有实际场景中的 ABA 问题。
这些索引必须能够在比较和交换操作(CAS)中使用,即
在支持 64 位 CAS 的标准架构上不会超过 64 位。
因此,可以假设 j
是一个无符号 64 位整数。
2.3 队列表示
队列有一个头部 H
,一个尾部 T
和一个大小为 n
的值数组。它们都是如 2 中所述的周期索引。
最初,队列是空的,头部和尾部都指向索引 0,但周期都为 1。
数组元素为 0,并表示为 (0,0)
。
最初为空的队列:
[ (0,0), (0,0), (0,0), (0,0) ] H=(1,0) T=(1,0)
最初满的队列,包含所有索引:
[ (0,0), (0,1), (0,2), (0,3) ] H=(0,0) T=(1,0)
2.4 头部和尾部单调性
我们始终在尾部插入(推送),在头部移除(弹出)。
头部和尾部都严格单调递增(使得 ABA 问题不太可能发生)。
即,推送操作使尾部增加 1(在 n
次推送后周期增加),而每次弹出操作使头部增加 1。
2.5 推送操作
push(y)
使尾部指向的位置的周期被替换为尾部的周期,同时该位置的元素被替换为 x
,通过 CAS 操作。
之后,尾部增加 1(可能不是立即,但在下一个推送生效之前)。
[ (c,?), (c,x), (c-1,?), (c-1,?) ] H=(c,1) T=(c,2)
push(y)
[ (c,?), (c,x), (c,y), (c-1,?) ] H=(c,1) T=(c,3)
我们只有在尾部指向的元素的周期恰好比尾部的周期小一个时才进行推送。
约束:我们不能推送超过 n
个元素(且我们的用例不需要这种情况)。
2.6 弹出操作
弹出操作读取头部索引处的值,如果周期与头部的周期匹配,并且 CAS 操作成功地将头部增加 1,则返回该值。
[ (c,?), (c,y), (c,x), (c-1,?) ] H=(c,1) T=(c,3)
弹出返回 y
[ (c,?), (c,x), (c,y), (c-1,?) ] H=(c,2) T=(c,3)
如果周期落后于头部的周期,队列为空(在此检查时)且没有返回任何东西。
2.7 头部与尾部关系
头部总是至多等于尾部,即 H <= T
。(技术上我们通常有 H - 1 <= T
,参见推送实现)
一般情况如下,以 c
表示值的周期以及头部和尾部。
- 头部和尾部在周期
c
上,并且*****
标记数组中逻辑上在队列中的区域。
[ c |*****c*****| c-1 ]
H = (c,i) T= (c,j)
- 头部比尾部晚一个周期。
[******c*****| c-1 |******c-1*****]
T = (c,i) H= (c-1,j)
- 空队列
[ c | c-1 ]
T = H = (c,i)
2.8 ABA 问题预防
注意,头部和尾部的单调性结合适当的 CAS 操作使得 ABA 问题在实践中不成问题。
此外,即使我们对索引要求更多位数,从而周期位数较少,仍然需要完整的 uint64
环绕才会发生 ABA 问题(以及重新插入相同的值)。
这只是因为如果最大周期较小,则周期长度 n
必须较大,我们每 n
次推送(或弹出)增加一个周期,但表示索引的数字每次操作增加 1。
2.9 无锁特性
声明: 队列的推送和弹出操作是无锁的。
- 推送操作不能任意长时间阻塞弹出操作,反之亦然。
- 此外,在并发的推送和弹出之间,每种操作中的一种总是在有限时间内成功。
证明概要:
- 只有推送操作会读/写尾部,而弹出操作不会
- 推送修改数组值,弹出读取并比较(CAS),
但我们最多可以有n
次推送,之后弹出将成功而不受推送的干扰 - 只有弹出操作会读/写/CAS 头部
- 由于实现中的循环/读/写/CAS 结构,因此声明得以成立
因此,推送和弹出操作不会相互干扰而阻塞对方。
推送操作可能会阻塞其他推送操作,弹出操作可能会阻塞其他弹出操作,因为可能存在饥饿现象,
但总会有一个操作会完成(因此进展)。这种进展表明队列是无锁的。
注意,队列没有公平性保证。从原则上讲,相同的推送线程或弹出线程可能总是成功,但在实践中这不太可能。
因此,队列不是等待自由的。
从技术上讲,这可以通过更复杂的逻辑来跟踪操作失败来部分缓解,
但在实践中似乎不值得这样做。具体而言,无锁队列可以实现等待自由,但整体吞吐量(相当)降低。
可以在观察到由于饥饿引起的问题后考虑这样做。
2.10 无锁队列
适用于一般(常规、可拷贝)数据类型 T
的无锁队列具有容量 N
,使用两个这样的索引队列和一个缓冲区来存储 T
类型的元素。
- 缓冲区:N 个存储单元,用于存储类型为 T 的值
- 队列 1:空闲索引 - 包含空闲的索引(对应的单元未使用)
- 队列 2:使用的索引 - 包含使用过的索引(对应的单元包含数据,视为在队列中)
最初,队列 1 是满的,包含所有索引 0, ... N-1
,队列 2 是空的。
我们现在概述主要操作的伪代码。
无锁队列推送
push (value)
- 尝试从队列 1 中获取一个空闲索引
i
(从空闲索引中弹出) - 如果获得
i
,则跳至第 6 步。 - 尝试从队列 2 中获取一个索引,但仅在此队列满时(从使用的索引中弹出)
- 如果没有获得
任何索引,则返回失败(队列满)。
5. 检查队列 1 是否仍然包含索引 i
(如果没有,则循环 2、3 步骤)。
6. 将 i
存储到缓冲区中,将 value
存储到 buffer[i]
。
7. 将 i
插入到队列 2 中(这表示 i
使用完毕并变为“已使用”)。
8. 返回成功。
无锁队列弹出
pop (value)
- 尝试从队列 2 中获取一个使用过的索引
i
(从使用的索引中弹出) - 如果获得
i
,则跳至第 6 步。 - 尝试从队列 1 中获取一个索引,但仅在此队列满时(从空闲索引中弹出)
- 如果没有获得任何索引,则返回失败(队列空)。
- 检查队列 2 是否仍然包含索引
i
(如果没有,则循环 1、2 步骤)。 - 将
i
存储到缓冲区中,将buffer[i]
存储到value
。 - 将
i
插入到队列 1 中(这表示i
现在是“空闲”)。 - 返回成功。
2.11 总结
通过描述无锁索引队列的基本构造和具体实现方法,我们可以理解无锁队列的设计和操作。这样的队列通过保证推送和弹出操作的非阻塞性来实现高效的数据交换,同时避免了传统队列实现中的锁定问题。