环形缓冲区也就是 RingBuffer,通常我们也叫它循环队列,本质就是一个 FIFO(先进先出)的队列。既然是队列,我们就要看看队列通常有哪几种实现方式,RingBuffer 又具有什么优势。
队列最简单的实现方式就是数组,这也是第一种方式。 维护一个数组和两个指针变量,head 指向队首,tail 指向队尾。入队 tail 指针加 1,出队 head 指针加 1,初始状态 head = tail = 0。
图片来源:《数据结构与算法之美》
上图执行了两次出队操作,a 和 b 先后出队。
不过这个方法有个问题,就是当 tail 移动到最后边,即使数组中还有空闲空间,也无法往队列中添加数据了。
上图数组中位置 0 和位置 1 的元素已经出队,有空闲空间。
所以,我们需要用到数据搬移。比如在 tail 移动到数组末尾时,且数组有空闲空间,集中进行一次数据搬移,然后重置设置 head 和 tail 指针的位置。
上图把 d-h 五个元素搬移到数组的头部,并入队新元素 j。
当然,你也可以进行数组扩容,比如把数组长度扩大为原来的 2 倍,然后就可以继续追加元素。但这样会造成内存空间的浪费,甚至是 OOM。所以我们通常还是做数据搬移,或者叫数据拷贝。
上面的逻辑,用 Java 实现的话,大概是这个样子:
// 用数组实现的队列
public class ArrayQueue {
// 数组:items,数组大小:n
private String[] items;
private int n = 0;
// head表示队头下标,tail表示队尾下标
private int head = 0;
private int tail = 0;
// 申请一个大小为 capacity 的数组
public ArrayQueue(int capacity) {
items = new String[capacity];
n = capacity;
}
// 入队操作,将item 放入队尾
public boolean enqueue(String item) {
// tail == n表示队列末尾没有空间了
if (tail == n) {
// tail ==n && head==0,表示整个队列都占满了
if (head == 0) return false;
// 数据搬移
for (int i = head; i < tail; ++i) {
items[i-head] = items[i];
}
// 搬移完之后重新更新head和tail
tail -= head;
head = 0;
}
items[tail] = item;
++tail;
return true;
}
// 出队
public String dequeue() {
// 如果head == tail 表示队列为空
if (head == tail) return null;
String ret = items[head];
++head;
return ret;
}
}
数据搬移(或者叫数据拷贝)这个操作看着不大,但它是个 O(n) 的操作,在一些对性能要求比较高的场景,比如 linux 内核态的操作(如网卡收发包、进程间通信、驱动层数据交互), 数据搬移基本是不能够接受的。
这个时候,聪明的你肯定想到,那我可以用链表实现队列,这样就不存在数据搬移的开销了。这就是第二种实现队列的方式,通常叫做链式队列。
上图用单链表实现的一个链式队列。
但链表的问题也不少。首先链表的每个节点都存了一个指针(指向下个节点),会占用更多内存空间。更重要的是,链表的节点是动态分配的(比如通过 malloc),在内存上不连续,会产生内存碎片,这对性能的影响就比较直接了。
这里有个背景知识,当CPU访问连续内存时,会利用缓存行(Cache Line)机制,一次性将一整块(例如64字节)连续数据从主内存(Main Memory)加载到高速缓存(L1 - L3 Cache)中,这样后续访问临近数据就非常快,能大幅提升效率。
图片来源:cloud.tencent.com.cn/developer/a…
比如有个数组 arr[10],CPU 访问 arr[0] 时,可能一次性把 arr[0] - arr[10] 都加载到了高速缓存中,下次访问 arr[1] 时,就不需要再从主内存中加载了。
链表实现的队列在内存上是不连续的,在高吞吐场景下,可能产生大量内存碎片,就很难利用到缓存行机制(Cache 命中率低)。
当然,在用户态的程序中(也就是我们写的代码),链表的应用还是比较多的,比如用 Hash + 双向链表实现一个 get/set O(1) 的 LRU Cache。但对于操作系统这种内核态的程序,性能要求非常高,就不适合用链表了。
有没有什么方法,既能够像数组一样能够使用连续的内存空间,又能够像链表一样,不存在数据搬移(数据拷贝)的开销呢?咱们的主角 RingBuffer(环形队列)刚好满足了上面的条件。
RingBuffer 的底层实现也是一个数组,所以在内存空间上是连续的。其次,所谓的环形队列,只是逻辑上的环形,并不是真的一个环形的数据结构。
和数组实现的队列很像,RingBuffer 依然是维护两个指针,唯一不同的是,因为逻辑上是环形,当 tail 移动到数组末尾,如果还有空间,就可以继续向后移动。
图片来源:《数据结构与算法之美》
比如上图,tail 移动到了 7,当再入队一个元素,如果 0 是空闲的,那 tail 就可以移动到 0。初始状态,head=tail,此时队列为空。当 tail 移动到 head 的前一个位置时,队列就满了。
上图 tail 移动到 3,head 在 4,队列已满
有人会问,不对呀,数组中明明还有一个空位置,怎么就满了。这是环形队列的一个问题,因为 head=tail 时队列为空,为了和队列为空的情况区分出来,就需要预留一个位置,当满足 (tail+1)%n = head 时,判定队列为满。
当然,如果想充分利用数组空间,也可以再新增一个变量 count,用来记录数组中元素的个数,当 count == n(n 是数组长度)时,直接判断队列为满。但这样就多维护了一个变量,每次入队出队操作还要更新这个变量,带来额外开销。所以前面那种方式更加常用,牺牲一点内存空间,换来更好的性能。
备注:可能你会觉得这点开销几乎等于没有,但在内核态,特别是当年硬件性能捉襟见肘的年代,每个开销都要被考虑到。
综上,前面那种实现(预留一个数组空间)会更常见一些。如果你觉得不好理解,可以直接看下 Java 版本的实现,也比较简单:
// 循环队列的 Java 版本
public class CircularQueue {
// 数组:items,数组大小:n
private String[] items;
private int n = 0;
// head表示队头下标,tail表示队尾下标
private int head = 0;
private int tail = 0;
// 申请一个大小为capacity的数组
public CircularQueue(int capacity) {
items = new String[capacity];
n = capacity;
}
// 入队
public boolean enqueue(String item) {
// 队列满了
if ((tail + 1) % n == head) return false;
items[tail] = item;
tail = (tail + 1) % n;
return true;
}
// 出队
public String dequeue() {
// 如果head == tail 表示队列为空
if (head == tail) return null;
String ret = items[head];
head = (head + 1) % n;
return ret;
}
}
到这里,我们再总结下,环形队列从逻辑上把数组抽象为环形结构,底层依然使用数组实现,在内存空间上保证了连续性(一次性申请固定内存),同时又避免了数据搬移(数据复制)的开销。
结合前面提到的 CPU 缓存行机制,相比链式队列,环形队列的 CPU 高速缓存命中率更高,我们常说环形队列(RingBuffer)对 Cache 友好,其实说的就是能更好利用缓存行机制。
连续内存、无数据搬移、Cache 友好,这些让 RingBuffer 成为实现高性能队列的首选。
另外还有一点要注意,在使用 RingBuffer 时,我们通常会预先分配一段连续内存空间,而不考虑扩容(虽然也能扩容)。因为扩容这个行为对性能和并发安全都是挑战,在性能优先的场景,通常不考虑扩容,在队列满时,可以丢弃旧的数据,也可以阻塞等待。
还没完,其实还有一个很重要的点没介绍,那就是 RingBuffer 能无锁(Lock-Free)实现并发安全的队列。特别是 SPSC(Single Producer Single Consumer)的场景。
队列这种数据结构会引申出一种工作模式,也就是生产者-消费者模式,生产者向队列写入数据,消费者从队列中读取数据。比如 Go 的 channel 就是一个本地的生产者-消费者模式,Kafka、RocketMQ 这种消息队列,本质是一个分布式的生产者-消费者模式。
生产者-消费者模式通常有四种具体场景:
- SPSC:Single Producer Single Consumer,即单生产者单消费者
- SPMC:Single Producer Multi Consumer,即单生产者多消费者
- MPSC:Multi Producer Single Consumer,即多生产者单消费者
- MPMC:Multi Producer Multi Consumer,即多生产者多消费者
我们以最简的 SPSC 的并发队列为例,这里的生产者和消费者通常是两个不同的线程,生产者线程向队列中写入数据,消费者线程从队列中读取数据。
如果我们用前面实现的 Java 版循环队列来应对这个场景,就会出现并发安全问题,因为两个线程在访问同一个数组。但对代码稍作改造,我们就能实现一个对SPSC 并发安全的 RingBuffer。
public class CircularQueue {
// 数组:items,数组大小:n
private final String[] items;
private final int n;
// 使用 volatile 保证 head 和 tail 变量在多线程间的可见性和有序性
// 生产者只写入 tail,消费者只读取 tail
// 消费者只写入 head,生产者只读取 head
private volatile int head = 0;
private volatile int tail = 0;
// 申请一个大小为capacity的数组
public CircularQueue(int capacity) {
items = new String[capacity];
// 环形缓冲区的实际大小,需要额外留出一个槽位来区分满和空的状态
n = capacity + 1;
}
// 入队 (SPSC 生产者)
public boolean enqueue(String item) {
// 预判队列是否满了:tail + 1 追上了 head
// 使用本地变量读取 head,减少 volatile 读取次数
final int currentHead = head;
if ((tail + 1) % n == currentHead) {
return false; // 队列满了
}
// 写入数据到当前 tail 位置
items[tail] = item;
// 更新 tail 指针,通过 volatile 写入,确保消费者能看到最新的数据和最新的指针位置
tail = (tail + 1) % n;
return true;
}
// 出队 (SPSC 消费者)
public String dequeue() {
// 预判队列是否为空:head == tail
final int currentTail = tail; // 使用本地变量读取 tail
if (head == currentTail) {
return null; // 队列为空
}
// 读取 head 位置的数据
String ret = items[head];
// 更新 head 指针,通过 volatile 写入,确保生产者能看到最新的 head 位置
head = (head + 1) % n;
// 注意:在实际的高性能实现中,通常会在此处清空 items[head] 避免内存泄露(如果存储的是对象引用)
// items[head] = null;
return ret;
}
}
在 Java 中使用的是 volatile 关键字(也可以用原子操作),volatile 关键字主要用于保证内存可见性和防止指令重排。这个场景下,head 仅由消费者线程修改、tail 仅由生产者线程修改,不存在多线程同时修改同一变量的情况,volatile 就够用了。
在其他语言中,使用的一般是原子操作。如果对 volatile 不太熟,也可以看下 Go 的版本:
package ringbuffer
import (
"sync/atomic"
)
// AtomicCircularQueue 是一个使用原子操作实现的无锁环形缓冲区(SPSC 场景)
type AtomicCircularQueue struct {
items []string
n int // 实际数组大小 n = capacity + 1
// 使用 atomic.Int32 来存储 head 和 tail,并利用原子操作进行读写
// 为了避免伪共享(false sharing),在高性能系统中通常会使用 padding
// 但在这里我们先使用最简洁的方式
head atomic.Int32
tail atomic.Int32
}
// NewAtomicCircularQueue 创建一个新的原子环形缓冲区
func NewAtomicCircularQueue(capacity int) *AtomicCircularQueue {
q := &AtomicCircularQueue{
// 需要额外留出一个槽位来区分满和空的状态
items: make([]string, capacity+1),
n: capacity + 1,
}
q.head.Store(0)
q.tail.Store(0)
return q
}
// Enqueue 入队 (SPSC 生产者)
func (q *AtomicCircularQueue) Enqueue(item string) bool {
// 原子读取 head
currentHead := q.head.Load()
currentTail := q.tail.Load()
// 队列满了
if (currentTail+1)%int32(q.n) == currentHead {
return false
}
// 写入数据
q.items[currentTail] = item
// 原子更新 tail 指针 (Release 语义确保数据写入先于指针更新被消费者看到)
newTail := (currentTail + 1) % int32(q.n)
q.tail.Store(newTail)
return true
}
// Dequeue 出队 (SPSC 消费者)
func (q *AtomicCircularQueue) Dequeue() (string, bool) {
// 原子读取 tail
currentHead := q.head.Load()
currentTail := q.tail.Load()
// 队列为空
if currentHead == currentTail {
return "", false
}
// 读取数据 (Acquire 语义确保能看到生产者写入的数据)
ret := q.items[currentHead]
// 更新 head 指针
newHead := (currentHead + 1) % int32(q.n)
q.head.Store(newHead)
// 可选:清空旧引用以避免内存泄露
q.items[currentHead] = ""
return ret, true
}
无论是 volatile 还是原子操作,都没有在代码层面加锁。
到这里我们可以思考下,如果我们使用的是数组实现的顺序队列,或者链表实现的链式队列,能够做到无锁实现 SPSC 并发安全的队列么?
进一步,可以基于 RingBuffer 实现无锁的 MPSC、SPMC、MPMC 并发队列,不过引入了 CAS,就不能完全做到无等待(Wait-Free)了。具体对比可以参考以下表格(Gemini 总结):
上图为 Google Gemini 总结。
Lock-Free:保证整个系统总是在前进。即使某个线程被挂起,其他线程也能继续工作。 Wait-Free:保证每个线程都能在有限的步骤内完成操作。
对于一些高性能场景(高吞吐低延迟),都尽量会使用 SPSC,或者转化为多个 SPSC。比如 Linux 内核的 io_uring、kfifo 模块,用户态的日志系统、音视频处理,均使用了 SPSC 的 RingBuffer。
上图为豆包总结。
引申:Go 的 channel 是一个有锁的 MPMC 模型,有兴趣可以研究下,为什么 Go 的 channel 没有做成无锁的 MPMC 模型。
到这里,可以再总结一下了,连续内存、无数据搬移、Cache 友好、无锁实现并发,这些就是用 RingBuffer 实现队列的核心优势。
当然 RingBuffer 也不是银弹,前面提到使用 RingBuffer 时通常不扩容,而是预先分配内存,从而能够最大程度利用这些优势。如果你的场景是灵活性优先而非性能优先,也就是希望能够动态扩展缓冲区,那链表是更好的选择(比如前面提到的 LRU Cache)。
如上图,维基百科也介绍了 RingBuffer 的应用场景