前言
本文将描述一个关于在多生产者、多消费者的场景的数据队列设计。通常在有一般并发场景下,使用mutex + slice就足够处理数据传递问题,在进一步强调开发效率下,使用channel也可以很好的完成任务,但如果是高压的并发场景下,队列容易成为整个系统的性能瓶颈,这时候问题会变成:
- 高并发下,是否会出现明显的锁竞争
- 队列操作是否会成为热点路径
- 每次入队、出队是否足够轻量
- 是否可以减少阻塞、降低调度开销
- 是否可以利用CPU cache,减少同步机制
本文会讨论这样一个队列:基于 CAS 和 sequence 的无锁环形队列(Lock-Free RingBuffer)。
它的核心目标是解决多生产者、多消费者(MPMC) 场景下的高吞吐、低争用的通用数据通道。
代码来自:https://github.com/liningyu7569/GSCAN/blob/main/pkg/queue/queue.go
它是一个关于实现无状态下的网络扫描引擎骨架,队列用于将扫描结果投递给下一步生产者的队列。
代码
go
type slot[T any] struct {
sequence uint64
data T
}
type LockFreeRingBuffer[T any] struct {
capacity uint64
mask uint64
_pad0 [56]byte
head uint64
_pad1 [56]byte
tail uint64
_pad2 [56]byte
buffer []slot[T]
}
func NewLockFreeRingBuffer[T any](capacity uint64) *LockFreeRingBuffer[T] {
if capacity&(capacity-1) != 0 {
panic("capacity 必须是 2 的幂")
}
rb := &LockFreeRingBuffer[T]{
capacity: capacity,
mask: capacity - 1,
buffer: make([]slot[T], capacity),
}
for i := uint64(0); i < capacity; i++ {
rb.buffer[i].sequence = i
}
return rb
}
func (rb *LockFreeRingBuffer[T]) Push(data T) bool {
var cell *slot[T]
var pos uint64
for {
pos = atomic.LoadUint64(&rb.head)
cell = &rb.buffer[pos&rb.mask]
seq := atomic.LoadUint64(&cell.sequence)
dif := int64(seq) - int64(pos)
if dif == 0 {
if atomic.CompareAndSwapUint64(&rb.head, pos, pos+1) {
break
}
} else if dif < 0 {
return false
} else {
// 并发竞争,让出时间片
runtime.Gosched()
}
}
cell.data = data
atomic.StoreUint64(&cell.sequence, pos+1)
return true
}
func (rb *LockFreeRingBuffer[T]) Pop(data *T) bool {
var cell *slot[T]
var pos uint64
for {
pos = atomic.LoadUint64(&rb.tail)
cell = &rb.buffer[pos&rb.mask]
seq := atomic.LoadUint64(&cell.sequence)
dif := int64(seq) - int64(pos+1)
if dif == 0 {
if atomic.CompareAndSwapUint64(&rb.tail, pos, pos+1) {
break
}
} else if dif < 0 {
// 缓冲区空
return false
} else {
runtime.Gosched()
}
}
*data = cell.data
atomic.StoreUint64(&cell.sequence, pos+rb.mask+1)
return true
}
为何在MPMC场景下,队列成为了性能瓶颈
来对比典型实现方式:
- 多个生产者协程不断写入任务、事件、结果
- 多个消费者不断取出处理
- 队列位于所有线程共享路径上
这种场景下的,最容易出现问题的是同步机制。
在低并发时,队列只是一个普通容器;在高并发时,队列会变成: - 所有生产者竞争写入
- 所有消费者竞争读取
- 所有线程频繁共享热点内存
所以队列性能会受制于:
锁竞争
如果队列使用互斥锁保护,多个生产者、消费者会围绕锁展开竞争,当并发数上升时,这种竞争无可避免会被一直放大。
阻塞与唤醒成本
线程如果因为锁或者条件变量阻塞,那么就会引入调度器唤醒、上下文切换等额外开销,如果是高频入列出列来说,这些损耗就是台面上的性能损失。
缓存一致性压力
队列头尾指针是共享状态,多个CPU核频繁修改它们,会产生缓存抖动,即便没有显式锁,也不意味着没有同步机制
分配与GC压力
如果队列依赖链表实现方式,动态扩容、频繁对象创建,在高吞吐下内存分配和垃圾回收也是成本
所以,MPMC队列的问题,不只是"线程安全",更多的还有如何在共享状态少、同步颗粒度极小的前提下,维持高吞吐。
无锁环形队列应对MPMC
无锁环形队列可以很好的应对MPMC情况,因为它更好满足这个关键:
- 它是有界的
容量固定、内存使用可预测、不会无线膨胀
对于数据通道这类场景下,有界是一种有点,因为内存可控,行为也可控 - 它是连续内存
底层是数组的形式,不是链表。这意味着更好的cache locality,也描述了更少的分配开销。 - 适合短操作
入队和出队的关键路径非常短:
读位置、检查状态、CAS抢占、读/写、发布状态。 - 天生适合"多方争抢固定槽位"
生产者竞争下一个可写槽位,消费者竞争下一个可读槽位;大部分竞争会被限制在一个小范围内,不必像互斥锁那般将整个队列串行化。
核心结构
go
type slot[T any] struct {
sequence uint64
data T
}
type LockFreeRingBuffer[T any] struct {
capacity uint64
mask uint64
_pad0 [56]byte
head uint64
_pad1 [56]byte
tail uint64
_pad2 [56]byte
buffer []slot[T]
}
其中关键是:
- 固定容量
- 按槽位管理状态
- 每个槽位维护sequence
- 通过CAS推进head/tail
- 通过padding避免伪共享
- 通过泛型支持任意数据类型
本质是一个有界的MPMC无锁环形队列。
设计核心:槽位sequence
传统环形的队列的核心在于head和tail
但是这里支持它可以在MPMC安全工作的前提是这个字段:
go
sequence uint64
每个槽位都有自己的sequence,它的作用不仅仅是纪录顺序,而是:
- 标识这个槽位处于哪一轮
- 判断当前槽位是可读还是可写
- 帮助生产者和消费者识别自己是否拿到正确槽位
- 避免传统环形队列里"空"/"满"边界区分问题
这是这类MPMC ring buffer的核心思想。
初始化时
go
for i := uint64(0); i < capacity; i++ {
rb.buffer[i].sequence = i
}
也就是说,第 i 个槽位初始的sequence就是 i 。
它表明一开始就属于"第 i 个写入位置",所以是可写的。
在之后,每一次完成读写,这个sequence都会推进到下一阶段,它会被用作标记槽位状态的变化。
所以这里实现的是,槽位状态不是靠额外的标志判断,而是靠当前sequence与当前位置的pos的关系判断。
这点非常重要,因为它可以让队列在高并发下,仍然可以清晰的区分:
- 这个位置是否已经被写好
- 这个位置是否被消费完毕
- 当前线程是否竞争到了正确槽位
- 队列到底是空还是满
Push 操作
先看Push主逻辑:
go
func (rb *LockFreeRingBuffer[T]) Push(data T) bool {
var cell *slot[T]
var pos uint64
for {
pos = atomic.LoadUint64(&rb.head)
cell = &rb.buffer[pos&rb.mask]
seq := atomic.LoadUint64(&cell.sequence)
dif := int64(seq) - int64(pos)
if dif == 0 {
if atomic.CompareAndSwapUint64(&rb.head, pos, pos+1) {
break
}
} else if dif < 0 {
return false
} else {
runtime.Gosched()
}
}
cell.data = data
atomic.StoreUint64(&cell.sequence, pos+1)
return true
}
过程化,分为四步:
- 读取head,映射槽位
go
pos = atomic.LoadUint64(&rb.head)
cell = &rb.buffer[pos&rb.mask]
head表示当前候选写到位置
容量是2的幂,所以使用:
go
pos & rb.mask
操作来代替 % capacity ,位运算比取模更加轻,尤其是大量Push/Pop下会很划算。
- 检查槽位状态
go
seq := atomic.LoadUint64(&cell.sequence)
dif := int64(seq) - int64(pos)
这里的差值是非常关键的:
- dif == 0 :表明槽位是当前可写的位置,写写
- dif < 0 :表明消费者还未释放槽位,队列满了
- dif > 0 :表明别的生产者已经推进了这一步,当前线程读到的是竞争态,需要重试这一轮。
这就是sequence的使用方式:
通过计算与pos的差值,来判断当前槽位状态。
- CAS抢占
go
if atomic.CompareAndSwapUint64(&rb.head, pos, pos+1) {
break
}
只有CAS成功的生产者,才会拿到这个位置,失败说明别的生产者已经先一步成功,需要继续重试。
注意这里的重点不是"完全没有竞争",而是把竞争缩小为一个非常短的原子步骤。
比起"整段入队逻辑被锁保护",这种方式的冲突窗口小得多。
- 写入数据并发布可读状态
go
cell.data = data
atomic.StoreUint64(&cell.sequence, pos+1)
这里的顺序不能反,先写 data 再写sequence
因为sequence被设置为pos + 1 后,消费者就会计算判断出这个位置为可读,所以sequence实际上充当了一个发布信号。
Pop操作
Pop逻辑与Push镜像完成:
go
func (rb *LockFreeRingBuffer[T]) Pop(data *T) bool {
var cell *slot[T]
var pos uint64
for {
pos = atomic.LoadUint64(&rb.tail)
cell = &rb.buffer[pos&rb.mask]
seq := atomic.LoadUint64(&cell.sequence)
dif := int64(seq) - int64(pos+1)
if dif == 0 {
if atomic.CompareAndSwapUint64(&rb.tail, pos, pos+1) {
break
}
} else if dif < 0 {
return false
} else {
runtime.Gosched()
}
}
*data = cell.data
atomic.StoreUint64(&cell.sequence, pos+rb.mask+1)
return true
}
- 读取tail,定位候选可读槽位
go
pos = atomic.LoadUint64(&rb.tail)
cell = &rb.buffer[pos&rb.mask]
- 检查槽位是否可读
go
seq := atomic.LoadUint64(&cell.sequence)
dif := int64(seq) - int64(pos+1)
这里的含义出现变化:
- dif == 0 :数据已经写好,可读
- dif < 0 :这个槽位还无法读,队列为空
- dif > 0 : 说明读位置可能已经被其他消费者推进,需要重试
- CAS抢占读权
go
if atomic.CompareAndSwapUint64(&rb.tail, pos, pos+1) {
break
}
这里同样,只有CAS成功的消费者,才能拥有这个元素
- 读取数据并且回收槽位
go
*data = cell.data
atomic.StoreUint64(&cell.sequence, pos+rb.mask+1)
这里的pos+rb.mask+1 本质上等同于pos + capacity
意识是:当前槽位已经完成一轮消费,进入下一轮"可写"状态,也就是说sequence的推进不是简单的加1,而是跟环的轮次绑定。
这是这类可以实现 ring buffer 环绕多圈后依然可以正确工作的关键。
它到底实现了怎么样的优化
容量限定于2的幂
go
if capacity&(capacity-1) != 0 {
panic("capacity 必须是 2 的幂")
}
这样做的好处是,索引时可直接:
go
pos & mask
代替 % capacity ,这在高频队列中,是完全值得做的
每个维护槽位sequence
它的收益来源:
- 准确判断槽位生命周期
- 避免环形队列空满歧义
- 允许多个生产者、消费者并发竞争位置
- 让head/tail只负责推进进度,而不承载所有的状态
判断
换句话说,head/tail是全局游标,sequence才是局部状态机。
CAS替代锁
这份代码没有使用mutex把Push/Pop保护起来,而是只在推进位置时做原子CAS。
优势是:
- 冲突窗口非常小
- 不会把整个操作串行化
- 不会因为线程阻塞而导致锁等待链条拉长
- 更适合短临界区、高频路径
队列会利用它,把竞争降低,讲解成更细粒度的原子冲突
padding避免伪共享
go
_pad0 [56]byte
head uint64
_pad1 [56]byte
tail uint64
_pad2 [56]byte
上述通过填充,做实际缓存优化。
因为head和tail会被高频修改,如果落在同一行的cache line,就会引发频繁的cache line invalidation,也就是缓存的同步问题,即便逻辑上无锁,那么也会因为CPU缓存一致性协议而付出代价。
通过padding,可以让head和tail隔开,减少伪共享。
预分配数组,减少GC和分配开销
go
buffer: make([]slot[T], capacity)
整个缓冲区初始化一次性分配好,运行期间不在额外分配内存,相对比与链表来说:更少的内存分配、更少的指针跳转、更少的cache locality 、更低的GC压力
泛型支持,提供复用性
go
type LockFreeRingBuffer[T any] struct
这表明通过泛型可以支持任意一个业务对象队列,是一个通用组件,当然从性能和解决问题的角度出发,这样的ring buffer 更加适合:
- 小对象
- 紧凑结构体
- 可直接拷贝数据
如果T很大,那么值拷贝成本就会上升,这时候需要考虑改为指针。
runtime.Gosched() 退让策略
在CAS竞争或者槽位状态不匹配时,代码不会空转,而是:
go
runtime.Gosched()
这是一种温和退让方式。避免在竞争激烈时,直接空转自旋持续占用CPU。
这样做它的优点是什么呢
全局状态少
真正共享全局状态的只有head和tail
而且每个槽位都由自己的sequence独立维护,这比整个队列都用一个锁保护的共享粒度小很多。
读写职责分离
生产者只竞争写位置,消费着只竞争读位置,双方交汇点不是互斥锁,而是槽位的生命周期和sequence。
有界结构
有界意味着:
- 不需要扩容
- 无需迁移元素
- 无需动态增加节点
- 队列行为可预测
在MPMC的高压场景下,有界结构是更加稳定安全的
对比其他实现方式
1.对比mutex + slice/list
使用原生容器+mutex锁是最容易最简单的实现方式,它易维护且对于并发压力不大的情况下,使用这样的组合是非常合理的,缺点是:多生产者、多消费者下锁竞争明显;入队出队容易被串行化;高频短操作时,锁的开销可能会超过业务本身。
2.对比channel
使用chan来进行数据通信,无疑是一个合理的事情,特别是它原生支持、语义清晰、唤醒/阻塞模型好用、可以做goroutine间同步和背压,chan自然是非常好用的,但是同样的问题仍然是,在高频小对象下,chan额外同步和调度成本也会偏高,对于容量、行为、退让策略的控制粒度不如自定义的 ring buffer 、难以在内部嵌入更加细节的缓存布局优化,总之就是chan适用于大部分场景,但是如果需要一个固定容量、非阻塞、高频、小对象的MPMC队列时,自定义ring buffer更加合适。
这个队列适用于什么
队列本质就是一个通用MPMC数据通道,适用于:
- 多worker产出任务结果时,多处理线程消费
- 日志事件缓冲
- 指标采集流水线
- 实时消息预处理
- 高并发任务调度中的中间结果传递
- 固定容量、高吞吐、可接受非阻塞失败的系统组件
那么它不适用于什么呢:
- 队列无界的情况
- 并发不高,这样队列不是瓶颈,没必要这样大费周折
- 元素很大,值拷贝不划算
- 业务需要阻塞等待,而不是快速失败重试
这种情况下,使用chan、mutex等等,是更加合适的
总结
这份代码实现了,面向多生产者、多消费者场景下有界无锁环形队列。
它适用高并发不在无锁本身,而是将队列设计为一条高性能数据通道:
- 固定容量数组 保证连续内存和可预测行为
- 用2的幂容量 + mask压缩索引开销
- 用CAS推进head/tail,把冲突限制在极短原子步骤里
- 用cache line padding降低伪共享
- 用sequence管理生命周期、并发状态
- 用预分配避免频繁分配和GC干扰
- 用泛型支持更多数据场景
和传统的加锁队列相比,它更适合高频、短路径、强吞吐的 MPMC 场景;
和 channel 相比,它的控制粒度更细,更适合作为一个专门优化过的底层数据结构;
和链表式无锁队列相比,它在有界场景下通常拥有更好的缓存局部性和更低的运行时成本。
它牺牲了一部分实现简单性,换来了更好的吞吐和更强的并发适应性。
所以真正的结论不是"无锁一定更好",而是:
当你的系统已经进入多生产者、多消费者、高频共享队列的性能区间时,无锁环形队列是一种非常值得采用的设计。