一、为什么需要环形缓冲区
假设你在写一个音频播放器,生产者线程从网络不断接收音频数据,消费者线程按采样率读取播放。最直觉的做法是用一个动态数组当队列:新数据来了就 push,播放完就 shift。
问题在于 shift。JavaScript 的 Array.shift() 或 Python 的 list.pop(0) 会把剩余所有元素向前搬移一位,时间复杂度 O(n)。音频采样率 44.1kHz,每秒要出队 44100 次,每次搬移几千个元素——CPU 全在搬数据,播放直接卡顿。
换成链表呢?出队倒是 O(1) 了,但每个节点都要 malloc/free,堆分配的开销在实时系统里不可接受。更麻烦的是链表节点散落在内存各处,CPU 缓存命中率极低,指针追踪的开销比搬数据还大。
环形缓冲区解决了这两个问题:固定大小的数组,读写指针循环推进,入队出队都是 O(1),零内存分配。它不是什么新发明,而是实时系统和高性能队列的基石——从 Linux 内核的 ftrace 到金融交易系统的无锁队列,底层都是它。
二、现实类比
想象回转寿司店的传送带。传送带是固定长度的环形轨道,寿司师傅在某个位置放寿司(写),顾客在另一个位置取寿司(读)。师傅放一盘,写指针往前走一格;顾客取一盘,读指针往前走一格。轨道是环形的,指针走到尽头自动回到起点,不需要把剩下的寿司往前挪。
关键约束:传送带只有这么多位置。如果师傅放得太快、顾客吃得太慢,传送带就满了——要么师傅等一等(阻塞),要么把最旧的寿司扔掉(覆盖)。如果顾客吃得太快,传送带就空了——顾客只能等(阻塞)。这就是环形缓冲区的核心语义:固定容量、先进先出、读写解耦。
三、核心思想
环形缓冲区用一块固定大小的数组存储数据,维护两个指针:
- head(读指针):下一个可读位置,出队后
head = (head + 1) % capacity - tail(写指针):下一个可写位置,入队后
tail = (tail + 1) % capacity
指针走到数组末尾时,取模运算让它回到开头,形成环形。入队写 tail 位置再推进 tail,出队读 head 位置再推进 head,两者互不干扰——这正是无锁单生产者单消费者(SPSC)队列的基础。
上图中,位置 1-3 是已读数据(绿色),位置 4 是下一个写入位置(粉色),位置 5-7 和 0 是空闲位置。tail 追着 head 跑,head 追着 tail 跑,谁也追不上谁的时候就是满或空。
空 vs 满
这里有个经典问题:当 head == tail 时,到底是空还是满?三种解法:
- 维护计数器:额外记录元素个数,
count == 0为空,count == capacity为满。最直觉,但多一个变量要同步,无锁场景下增加复杂度。 - 浪费一个槽位:
tail的下一个位置是head时视为满,实际可用容量为capacity - 1。简单直接,是嵌入式系统最常用的方案。 - 单调递增序列号(Disruptor 方案):
head和tail不取模,一直递增,用sequence & (capacity - 1)算实际下标。空满判断靠序列号差值,不需要额外变量,也不浪费槽位。
2 的幂优化
如果容量是 2 的幂,取模可以用位与替代:index % capacity 等价于 index & (capacity - 1)。位与运算是单条 CPU 指令,取模可能触发除法指令(数十个时钟周期)。LMAX Disruptor 正是利用这个技巧,将序列号映射到数组下标:
// Disruptor RingBuffer.java 中的核心映射E elementAt(long sequence) { return entries[BUFFER_PAD + (int)(sequence & indexMask)];}其中 indexMask = capacity - 1,capacity 保证是 2 的幂。
复杂度
| 操作 | 时间复杂度 | 空间复杂度 |
|---|---|---|
| 入队 enqueue | O(1) | O(capacity) |
| 出队 dequeue | O(1) | O(capacity) |
| 查看队首 peek | O(1) | - |
| 判空/判满 | O(1) | - |
四、变体与对比
环形缓冲区不是唯一的队列实现。把它和动态数组队列、链表队列放在一起看,差异就很明显:
| 特性 | 环形缓冲区 | 动态数组队列 | 链表队列 |
|---|---|---|---|
| 入队 | O(1) | O(1) 均摊 | O(1) |
| 出队 | O(1) | O(n) 或 O(1) 均摊 | O(1) |
| 内存分配 | 零(初始化后) | 扩容时批量分配 | 每次入队分配 |
| 缓存友好性 | 极好(连续内存) | 好 | 差(指针追踪) |
| 容量 | 固定 | 动态增长 | 动态增长 |
| 无锁友好 | 是(SPSC) | 否 | 困难 |
| 内存开销 | 仅数组 | 数组 + 扩容冗余 | 节点 + 指针 |
动态数组队列的出队性能取决于实现:shift() 是 O(n),双指针实现是 O(1) 但扩容时需要搬移。链表队列虽然入队出队都是 O(1),但每个节点的堆分配在实时场景下是硬伤——malloc 的耗时不可预测,GC 介入时更不可控。
环形缓冲区的代价是固定容量。如果你不知道数据量的上界,或者需要随机访问中间元素,它就不是最佳选择。
五、多语言实现
Go 实现
Go 版本采用「浪费一个槽位」策略判空满,容量强制为 2 的幂以启用位与优化:
package ringbuffer
import "errors"
// RingBuffer 是固定大小的环形队列,容量为 2 的幂type RingBuffer[T any] struct { buf []T head uint64 // 读指针 tail uint64 // 写指针 mask uint64 // capacity - 1,用于位与取模}
// New 创建容量为 2^k 的环形缓冲区// 传入的 size 会向上取整到最近的 2 的幂func New[T any](size uint64) *RingBuffer[T] { // 向上取整到 2 的幂 cap := uint64(1) for cap < size { cap <<= 1 } return &RingBuffer[T]{ buf: make([]T, cap), mask: cap - 1, }}
// Enqueue 入队,满时返回错误func (rb *RingBuffer[T]) Enqueue(val T) error { next := rb.tail + 1 // 浪费一个槽位:tail+1 == head 表示满 if next&rb.mask == rb.head&rb.mask { return errors.New("ring buffer: 已满") } rb.buf[rb.tail&rb.mask] = val rb.tail = next return nil}
// Dequeue 出队,空时返回错误func (rb *RingBuffer[T]) Dequeue() (T, error) { var zero T if rb.tail == rb.head { return zero, errors.New("ring buffer: 已空") } val := rb.buf[rb.head&rb.mask] rb.buf[rb.head&rb.mask] = zero // 避免引用残留,帮助 GC rb.head++ return val, nil}
// Len 返回当前元素个数func (rb *RingBuffer[T]) Len() uint64 { // tail 和 head 不取模,差值就是元素数 return rb.tail - rb.head}几个设计要点:
head和tail用uint64一直递增,不回绕。实际下标通过& rb.mask计算,这就是 Disruptor 的单调序列号思路。Enqueue中next&rb.mask == rb.head&rb.mask判断满——等价于「写指针的下一个位置等于读指针」,浪费了一个槽位。Dequeue中把出队位置置为零值,防止引用类型数据无法被 GC 回收。这是 Go 环形缓冲区的常见陷阱。
TypeScript 实现
TypeScript 版本同样使用 2 的幂容量和位与取模,但用 count 计数器判空满,更易理解:
export class RingBuffer<T> { private buf: (T | undefined)[]; private head = 0; // 读指针 private tail = 0; // 写指针 private count = 0; // 当前元素数 private mask: number; // capacity - 1
constructor(capacity: number) { // 向上取整到 2 的幂 let cap = 1; while (cap < capacity) cap <<= 1; this.buf = new Array<T | undefined>(cap); this.mask = cap - 1; }
/** 入队,满时返回 false */ enqueue(val: T): boolean { if (this.count === this.buf.length) return false; this.buf[this.tail & this.mask] = val; this.tail++; this.count++; return true; }
/** 出队,空时返回 undefined */ dequeue(): T | undefined { if (this.count === 0) return undefined; const val = this.buf[this.head & this.mask]; this.buf[this.head & this.mask] = undefined; // 释放引用 this.head++; this.count--; return val; }
/** 查看队首元素 */ peek(): T | undefined { if (this.count === 0) return undefined; return this.buf[this.head & this.mask]; }
get length(): number { return this.count; }}TypeScript 版本用 count 判空满,逻辑更清晰,适合应用层使用。代价是多维护一个变量,在无锁场景下需要额外同步。如果只用于单线程,count 方案是最省心的选择。
六、生产验证
LMAX Disruptor
LMAX Disruptor 是金融交易系统 LMAX 开源的高性能进程内消息传递库,核心就是环形缓冲区。它的 RingBuffer.java 用单调递增的序列号代替传统读写指针,通过 sequence & indexMask 映射到数组下标,配合缓存行填充(cache line padding)避免伪共享。Disruptor 官方宣称单线程每秒可处理 600 万笔订单,比传统加锁队列快 3 个数量级。
- RingBuffer.java — 核心类定义与构造函数
- RingBufferFields —
indexMask、entries数组、elementAt位与映射
Linux 内核 ftrace
Linux 内核的性能追踪工具 ftrace 底层使用环形缓冲区记录追踪事件。每个 CPU 核心有独立的 ring_buffer_per_cpu 结构,包含 head_page、tail_page、commit_page 和 reader_page 四个指针,实现无锁的 per-CPU 写入。这种设计让 ftrace 在高负载下依然能低开销地记录内核事件,不会因为锁竞争影响被追踪的系统本身。
- ring_buffer.c —
ring_buffer_per_cpu和trace_buffer核心结构定义
Workiva Go Data Structures
Workiva 的 go-datastructures 库提供了生产级的 Go 环形缓冲区实现,支持阻塞和非阻塞两种入队模式,内部使用 mutex + condition variable 实现线程安全。该库被 Workiva 的金融数据平台广泛使用,处理实时数据流。
七、小结
适合使用环形缓冲区的场景:
- 固定大小的生产者-消费者队列,尤其是音频/视频/网络流处理
- 单生产者单消费者(SPSC)无锁队列,读写指针天然解耦
- 嵌入式和实时系统,要求零内存分配、确定性延迟
- 遥测和日志采集,允许覆盖最旧数据(overwrite-oldest),永不阻塞
不适合使用环形缓冲区的场景:
- 数据量无法预估上界,需要动态增长——环形缓冲区容量固定,满了只能阻塞或丢弃
- 需要按键随机访问,而非先进先出——环形缓冲区只支持顺序读写
- 元素大小差异大——环形缓冲区每个槽位等长,变长元素需要额外处理(外部分配 + 存索引)
- 多生产者多消费者(MPMC)场景——简单环形缓冲区的无锁特性仅限 SPSC,MPMC 需要更复杂的协调机制
八、参考资料
- LMAX Disruptor - 高性能无锁环形缓冲区,金融交易系统核心组件
- Linux kernel ring_buffer.c - 内核 ftrace 追踪的 per-CPU 无锁环形缓冲区实现
- Disruptor 论文 - LMAX 团队对 Disruptor 设计的完整技术说明
- Ring Buffer on Wikipedia - 环形缓冲区的经典定义与空满判断策略
- Workiva go-datastructures - Go 生产级数据结构库,含线程安全环形缓冲区
支持与分享
如果这篇文章对你有帮助,欢迎支持作者或分享给更多人
部分信息可能已经过时






