登录
推荐 文章 Go 技术 课程 下载 专题 AI
首页 >  Golang >  Go教程

Go sync.Cond 实现有界队列的等待条件

来源:17golang原创

时间:2026-10-15 08:42:11 498浏览 收藏

我第一次用 sync.Cond 做有界队列时,最容易误判的是“已经调用了 Signal,为什么消费者还是卡着”。后来排查发现,真正决定正确性的不是通知方法单独怎么写,而是状态、锁和等待条件是否始终放在同一个保护域里。

这篇只讨论三个边界:队列满时生产者等待,队列空时消费者等待,以及队列关闭后如何让两侧都能退出。官方资料:https://pkg.go.dev/sync#Cond

最小规则是:持有保护状态的锁检查条件,用 for 循环调用 Wait,状态改变后再通知对应等待者。关闭属于状态变化,不能只关闭一个入口而不唤醒另一侧。

先确认 Wait 的三个硬条件

sync.Cond 不是“睡眠一会儿再重试”的定时器。它关联一个 Locker,调用 Wait 时会原子地释放这把锁并挂起当前 goroutine,恢复前又会重新拿锁。因此,调用前必须已经持有 Cond.L。

第二个条件是:返回并不代表业务条件一定成立。另一个 goroutine 可能先拿到锁并消费了数据,也可能只是同一条件下的其他等待者被唤醒,所以判断必须写在循环里。

// 这个骨架只展示 Cond 的正确等待边界,不承载具体队列逻辑。
mu.Lock()
for !condition() {
    // Wait 会暂时释放 mu,唤醒后重新持有 mu 再返回。
    cond.Wait()
}
// 这里才允许读取或修改由 mu 保护的状态。
useProtectedState()
mu.Unlock()

如果现场看到 Wait 外面只有一个 if,第一步就应该把它改成 for,再继续判断通知是否遗漏。这个顺序能避免把“偶尔卡住”误判成调度问题。

Go 有界队列中未满条件、非空条件、互斥锁与两个 sync.Cond 的静态关系图
图1:有界队列用同一把锁保护长度和关闭状态,再分别用 notFull、notEmpty 表达生产者与消费者的等待条件。

把有界队列拆成未满和非空

有界队列同时存在两个相反方向的等待:生产者只在“未满”时才能追加,消费者只在“非空”时才能取出。用两个条件变量能让通知目标更清楚:成功入队后通知消费者,成功出队后通知生产者。

package main

import (
    "errors"
    "sync"
)

var ErrClosed = errors.New("queue is closed")

type Queue[T any] struct {
    mu       sync.Mutex
    items    []T
    capacity int
    closed   bool
    notFull  *sync.Cond // 队列满时,Put 在这里等待。
    notEmpty *sync.Cond // 队列空时,Get 在这里等待。
}

func NewQueue[T any](capacity int) *Queue[T] {
    if capacity 

这里的关键不是字段多,而是每个字段都只有一把锁负责。len(q.items)、q.closed 的读取,追加和移除,都发生在 q.mu 保护下;两个 Cond 只是等待和通知的入口,不自己保存队列状态。

为什么 Signal 不能替代状态判断

Signal 只负责唤醒一个等待者,不负责把条件变成真。假设两个消费者都在等非空,生产者入队一个元素后调用一次 Signal,只有一个消费者应该拿到这个元素,另一个消费者被唤醒也必须重新看到队列仍为空并继续等待。

同理,队列从容量为 1 的满状态变成有一个空位时,通知一个生产者通常就够了。只有当状态变化可能让多个等待者都需要重新判断,或者关闭让所有等待者都有退出机会时,才使用 Broadcast。

状态变化推荐通知重新检查的条件
追加一个元素notEmpty.Signal()队列是否仍有元素、是否已关闭
移除一个元素notFull.Signal()队列是否仍有空位、是否已关闭
进入关闭状态两个 Cond 都 Broadcast()关闭状态与队列剩余元素

关闭时要同时唤醒两侧等待者

关闭是最容易漏通知的地方。只给消费者调用 Broadcast,可能让“队列已满、正在等空位”的生产者永久睡眠;只唤醒生产者也会留下空队列消费者。关闭动作应该持有同一把锁,先写入 closed,再同时唤醒两类等待者。

func (q *Queue[T]) Close() {
    q.mu.Lock()
    defer q.mu.Unlock()

    if q.closed {
        return
    }
    // 先发布关闭状态,再唤醒所有等待者,让它们各自判断退出条件。
    q.closed = true
    q.notFull.Broadcast()
    q.notEmpty.Broadcast()
}

Get 不能一看到关闭就丢掉剩余元素。上面的实现允许消费者先把已有元素取完,只有“已关闭且为空”时才返回 ErrClosed。Put 则在重新拿到锁后立即拒绝新元素,这样关闭后的行为是固定的。

Go 有界队列关闭状态、Put 等待者、Get 等待者和 Broadcast 通知的静态关系图
图2:关闭把 closed 状态写入同一保护域,并同时唤醒两侧等待者,让 Put 和 Get 都能重新判断退出条件。

按现象排查:到底是哪一层没有闭环

现象一:一调用 Wait 就崩溃或行为异常

先看调用前是否拿到了 Cond.L。如果创建 Cond 时传入的是一把锁,后续却用另一把锁包围状态判断,等待和通知就不再围绕同一个保护域工作。最稳妥的写法是把锁和 Cond 放在同一个结构体中,并让所有入口都调用同一把锁。

现象二:偶尔取到空值,或者刚唤醒又继续卡住

检查 Wait 是否被写成 if,以及唤醒后是否跳过了 closed 和队列长度判断。Wait 返回只是“可以重新竞争锁”,不是“你的业务条件已经兑现”。

现象三:关闭后程序迟迟不退出

把所有可能阻塞的入口列出来:满队列里的 Put、空队列里的 Get、以及可能等待二次条件的包装方法。关闭必须修改共享状态并广播所有相关 Cond;如果只有一侧退出,另一侧仍可能永久等待。

现象四:通知越来越多但吞吐没有改善

不要把 Broadcast 当成“更保险的 Signal”。广播会唤醒一批 goroutine,它们随后要重新竞争同一把锁;如果一次只新增一个元素,大量唤醒只会制造竞争。优先使用精确的 Signal,把 Broadcast 留给关闭或确实影响所有等待者的状态变更。

Cond 什么时候值得用

如果只是等待一个事件,channel 往往更直观:发送表示一次事件,关闭 channel 表示永久广播。sync.Cond 更适合“共享状态本身很复杂,等待者需要在拿锁后检查多个字段”的场景,例如本例的容量、长度和关闭状态必须同时判断。

我的判断标准是:如果等待条件可以自然地变成一个 channel 协议,就优先用 channel;如果必须让多个操作围绕同一份可变状态保持原子关系,再考虑 Cond。无论选择哪种方式,都要明确关闭语义、剩余数据策略和等待者如何退出。

最后的检查清单

  • 每个 Cond 的 L 是否就是保护条件状态的那把锁。
  • 每次 Wait 是否位于 for 循环中。
  • 入队、出队和关闭是否都在同一把锁下修改状态。
  • 入队后是否通知非空,出队后是否通知未满。
  • 关闭是否广播所有可能睡眠的 Cond。
  • 关闭后的 Get 是否明确“读完剩余数据再退出”还是“立即丢弃”。

相关问题

为什么 Wait 返回后还要再次判断队列长度?

因为通知只代表条件可能发生变化,返回后还要重新拿锁确认当前状态;其他 goroutine 可能已经先消费或占用了这次变化。

有多个消费者时一定要 Broadcast 吗?

不一定。一次只增加一个元素时,Signal 通常足够;关闭或条件变化影响所有等待者时才需要 Broadcast。

有界队列能不能直接用两个 channel?

可以,channel 通常更适合固定容量和单向事件模型。Cond 的价值在于等待条件需要和多字段共享状态一起判断。

声明:本文转载于:17golang原创 如有侵犯,请联系study_golang@163.com删除
相关阅读
更多>
最新阅读
更多>
课程推荐
更多>