为什么要使用环形缓冲区?

61 阅读12分钟

环形缓冲区也就是 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 的应用场景