直接用 slice 做队列在多协程下会因底层数组共享导致数据竞争;正确做法是用 sync.mutex 封装 head/tail 索引实现并发安全队列,避免锁粒度过大或误用 channel。

为什么直接用 slice 做队列在多协程下会出问题
因为 slice 底层是共享的底层数组指针,多个 goroutine 同时调用 append 或修改 len/cap 会导致数据竞争(data race),Go 运行时加 -race 就能立刻报出类似 fatal error: concurrent map writes 的错误——虽然这里不是 map,但 race detector 对 slice 的写操作同样敏感。
常见误用:用一个全局 []int 变量 + sync.Mutex 包裹所有读写操作。这能避免 panic,但锁粒度过大,Enqueue 和 Dequeue 互相阻塞,吞吐掉得厉害。
- 不要在每次
append前都 lock 整个结构体 - 避免用
chan模拟队列(比如make(chan int, N))——它本质是带缓冲 channel,不是 FIFO 队列语义,且无法安全遍历、无法获取长度、无法 Peek - 别依赖第三方包如
container/list直接并发使用——它本身不提供并发安全保证
sync.Mutex + slice 的最小可行封装怎么写
核心是把锁控制在「真正需要互斥的临界区」:只锁入队/出队逻辑本身,不锁内存分配或业务处理。典型实现是维护头尾索引,复用底层数组,避免频繁 alloc。
type Queue struct {
mu sync.Mutex
data []int
head int
tail int
}
<p>func (q <em>Queue) Enqueue(v int) {
q.mu.Lock()
defer q.mu.Unlock()
if q.tail == len(q.data) {
// 扩容:双倍增长,保留旧数据
newData := make([]int, len(q.data)</em>2)
copy(newData, q.data[q.head:q.tail])
q.data = newData
q.head = 0
q.tail = len(q.data) / 2
}
q.data[q.tail] = v
q.tail++
}</p><p>func (q *Queue) Dequeue() (int, bool) {
q.mu.Lock()
defer q.mu.Unlock()
if q.head >= q.tail {
return 0, false
}
v := q.data[q.head]
q.head++
return v, true
}</p>
-
head和tail是关键状态变量,必须被同一把锁保护 - 扩容时用
copy而非append,避免触发 slice 内部再分配导致竞争 - 返回
(value, ok)是 Go 习惯,比 panic 或 error 更适合高频调用场景
用 sync.Pool 优化高频小队列的内存分配
如果队列生命周期短(比如每个 HTTP 请求建一个临时队列做中间计算),反复 new/slice alloc 会造成 GC 压力。这时 sync.Pool 能复用结构体实例,但注意:它不保证对象一定被复用,也不能存放含 finalizer 的值。
正确做法是把整个 Queue 结构体放 pool,而不是只 pool data 字段:
var queuePool = sync.Pool{
New: func() interface{} {
return &Queue{
data: make([]int, 0, 8), // 初始 cap=8,减少首次扩容
}
},
}
<p>func GetQueue() <em>Queue {
return queuePool.Get().(</em>Queue)
}</p><p>func PutQueue(q *Queue) {
q.head = 0
q.tail = 0
queuePool.Put(q)
}</p>
- 每次
Put前必须重置head/tail,否则下次Get会拿到脏状态 - 不要在
Put里清空dataslice(如q.data = q.data[:0]),因为底层数组可能还在被其他 goroutine 引用 -
sync.Pool不适合长期存活的队列(比如全局任务队列),它会在 GC 时主动丢弃缓存
什么时候该换用 chan 而不是手写队列
如果你的场景满足以下任意一条,直接用 chan 更简单、更安全、性能也不差:
- 生产者和消费者数量固定且明确(比如 1 生 1 消,或 3 生 2 消)
- 不需要随机访问、不需要知道当前长度、不需要 Peek 操作
- 可以接受阻塞行为(
ch 或 <code>)而非轮询或超时控制 - 消息类型单一,且生命周期由 channel 自动管理(close 后所有 recv 返回 zero value)
例如:jobs := make(chan string, 100) 配合 for range jobs 消费,天然并发安全,无需任何额外同步原语。
真正需要手写队列的场景,往往是:要支持批量出队(DequeueN(n))、要统计精确长度、要支持优先级、要对接已有 ring buffer 接口、或者底层必须复用某块预分配内存——这些 chan 做不到。
golang免费学习笔记(深入):立即使用
在学习笔记中,你将探索golang的核心概念和高级技巧!











