Go 实现无锁环形队列:面向多生产者多消费者的高性能 MPMC 设计

前言

本文将描述一个关于在多生产者、多消费者的场景的数据队列设计。通常在有一般并发场景下,使用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情况,因为它更好满足这个关键:

  1. 它是有界的
    容量固定、内存使用可预测、不会无线膨胀
    对于数据通道这类场景下,有界是一种有点,因为内存可控,行为也可控
  2. 它是连续内存
    底层是数组的形式,不是链表。这意味着更好的cache locality,也描述了更少的分配开销。
  3. 适合短操作
    入队和出队的关键路径非常短:
    读位置、检查状态、CAS抢占、读/写、发布状态。
  4. 天生适合"多方争抢固定槽位"
    生产者竞争下一个可写槽位,消费者竞争下一个可读槽位;大部分竞争会被限制在一个小范围内,不必像互斥锁那般将整个队列串行化。

核心结构

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
}

过程化,分为四步:

  1. 读取head,映射槽位
go 复制代码
pos = atomic.LoadUint64(&rb.head)
cell = &rb.buffer[pos&rb.mask]

head表示当前候选写到位置

容量是2的幂,所以使用:

go 复制代码
pos & rb.mask

操作来代替 % capacity ,位运算比取模更加轻,尤其是大量Push/Pop下会很划算。

  1. 检查槽位状态
go 复制代码
seq := atomic.LoadUint64(&cell.sequence)
dif := int64(seq) - int64(pos)

这里的差值是非常关键的:

  • dif == 0 :表明槽位是当前可写的位置,写写
  • dif < 0 :表明消费者还未释放槽位,队列满了
  • dif > 0 :表明别的生产者已经推进了这一步,当前线程读到的是竞争态,需要重试这一轮。
    这就是sequence的使用方式:
    通过计算与pos的差值,来判断当前槽位状态。
  1. CAS抢占
go 复制代码
if atomic.CompareAndSwapUint64(&rb.head, pos, pos+1) {
	break
}

只有CAS成功的生产者,才会拿到这个位置,失败说明别的生产者已经先一步成功,需要继续重试。

注意这里的重点不是"完全没有竞争",而是把竞争缩小为一个非常短的原子步骤。

比起"整段入队逻辑被锁保护",这种方式的冲突窗口小得多。

  1. 写入数据并发布可读状态
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
}
  1. 读取tail,定位候选可读槽位
go 复制代码
pos = atomic.LoadUint64(&rb.tail)
cell = &rb.buffer[pos&rb.mask]
  1. 检查槽位是否可读
go 复制代码
seq := atomic.LoadUint64(&cell.sequence)
dif := int64(seq) - int64(pos+1)

这里的含义出现变化:

  • dif == 0 :数据已经写好,可读
  • dif < 0 :这个槽位还无法读,队列为空
  • dif > 0 : 说明读位置可能已经被其他消费者推进,需要重试
  1. CAS抢占读权
go 复制代码
if atomic.CompareAndSwapUint64(&rb.tail, pos, pos+1) {
	break
}

这里同样,只有CAS成功的消费者,才能拥有这个元素

  1. 读取数据并且回收槽位
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 相比,它的控制粒度更细,更适合作为一个专门优化过的底层数据结构;

和链表式无锁队列相比,它在有界场景下通常拥有更好的缓存局部性和更低的运行时成本。

它牺牲了一部分实现简单性,换来了更好的吞吐和更强的并发适应性。

所以真正的结论不是"无锁一定更好",而是:

当你的系统已经进入多生产者、多消费者、高频共享队列的性能区间时,无锁环形队列是一种非常值得采用的设计。

相关推荐
Lyyaoo.2 小时前
【JAVA基础面经】线程的状态
java·开发语言
John.Lewis2 小时前
C++进阶(8)智能指针
开发语言·c++·笔记
CoderCodingNo2 小时前
【GESP】C++二级真题 luogu-B4497, [GESP202603 二级] 数数
开发语言·c++·算法
ss2732 小时前
致Java初学者的一封信
java·开发语言
We་ct2 小时前
LeetCode 50. Pow(x, n):从暴力法到快速幂的优化之路
开发语言·前端·javascript·算法·leetcode·typescript·
阿里嘎多学长2 小时前
2026-04-12 GitHub 热点项目精选
开发语言·程序员·github·代码托管
EnCi Zheng2 小时前
P2G-Python字符串方法完全指南-split、join、strip、replace的Python编程利器
开发语言·python
爱学习的小囧2 小时前
VCF 9 实验室网络部署全攻略:从硬件连接到配置实操
开发语言·网络·php
liliangcsdn3 小时前
LLM如何与mcp server交互示例
linux·开发语言·python