The sync Cond Type in Go Explained With an Example
How sync.Cond works in Go: Wait in a loop, Signal and Broadcast, the mutex rules, a bounded queue example, lost wake-ups and when a channel is simpler.

A mutex answers who goes first. It does not answer "wait here until something has happened". A goroutine that needs a queue to have an item, a buffer to have room or a counter to reach zero has to wait for the state to change, and the naive ways to do that are bad: spinning in a loop burns a CPU, and a loop that unlocks, sleeps and relocks adds latency and misses the moment. sync.Cond is the standard library's condition variable, the tool made for exactly this.
Tested with Go 1.25.6, macOS on Apple Silicon (arm64)
It is also the type Go programmers meet least, because channels cover most of the same ground. This article explains how it works, builds a bounded queue with it, shows the mistakes, and says when a channel is the better choice. It belongs to the concurrency material and builds on mutexes. Everything ran on Go 1.25.6.
The model
A Cond is created with a Locker, normally a *sync.Mutex, and has three methods:
Wait()atomically unlocks the mutex and suspends the goroutine. When it is woken, it locks the mutex again before returning.Signal()wakes one goroutine waiting on the condition, if any.Broadcast()wakes all of them.
The atomic unlock-and-sleep is the reason the type exists. Without it, a goroutine would unlock, and in the gap before it sleeps another goroutine could change the state and signal, and the wake-up would be missed. With it, no signal can slip through between the check and the sleep, because the signaller needs the same mutex to change the state.
The pattern for a waiter is fixed:
c.L.Lock()
for !condition() {
c.Wait()
}
// condition holds, and the lock is held
c.L.Unlock()The loop, not an if, is essential. After Wait returns the goroutine holds the lock, but another waiter may have got in first and consumed what the condition promised, or the wake-up may have been broadcast to everyone. Re-checking is cheap and makes the code correct however the wake-up came about.
A worked example: a bounded queue
A queue with a capacity needs two waits: consumers wait while it is empty, producers wait while it is full. Two conditions on one mutex is the case where Cond is natural.
package main
import "sync"
// Queue is a bounded FIFO safe for concurrent use.
type Queue struct {
mu sync.Mutex
notEmpty *sync.Cond
notFull *sync.Cond
items []int
cap int
}
func NewQueue(cap int) *Queue {
q := &Queue{cap: cap}
q.notEmpty = sync.NewCond(&q.mu)
q.notFull = sync.NewCond(&q.mu)
return q
}
// Put blocks while the queue is full.
func (q *Queue) Put(v int) {
q.mu.Lock()
defer q.mu.Unlock()
for len(q.items) == q.cap {
q.notFull.Wait()
}
q.items = append(q.items, v)
q.notEmpty.Signal() // one consumer can proceed
}
// Get blocks while the queue is empty.
func (q *Queue) Get() int {
q.mu.Lock()
defer q.mu.Unlock()
for len(q.items) == 0 {
q.notEmpty.Wait()
}
v := q.items[0]
q.items = q.items[1:]
q.notFull.Signal() // one producer can proceed
return v
}Both methods lock, wait in a loop for their condition, change the state, and signal the opposite condition. Two Cond values share one mutex, so there is a single lock protecting the slice and each condition has its own wait queue, which avoids waking producers when a consumer is needed. Running it with a slow consumer and several producers shows the capacity being respected:
package main
import (
"fmt"
"sync"
"time"
)
func main() {
q := NewQueue(2)
var wg sync.WaitGroup
for p := 1; p <= 3; p++ {
wg.Go(func() {
for i := 0; i < 3; i++ {
q.Put(p*10 + i)
}
})
}
sum := 0
count := 0
for count < 9 {
sum += q.Get()
count++
time.Sleep(2 * time.Millisecond) // a slow consumer
}
wg.Wait()
fmt.Println("consumed", count, "items, sum", sum)
}consumed 9 items, sum 189Nine items from three producers pass through a queue of capacity two. The producers block in notFull.Wait() whenever the queue is full and resume as the consumer drains it, and the sum comes out the same on every run (33 + 63 + 93 = 189), though the order of items varies. No item is lost and the queue never holds more than two.
Signal or Broadcast
Signal wakes one waiter and is right when one unit of state change can serve one waiter, as in the queue: one item added, one consumer woken. Broadcast is right when a change might satisfy many waiters or different kinds of waiter, for example a flag flipping from "not ready" to "ready", or a state change after which several waiters' conditions might be true. If you cannot argue that one waiter suffices, use Broadcast; it costs wake-ups, and the loops make extra wake-ups harmless. Using Signal where Broadcast was needed leaves goroutines asleep forever, a hang with no error.
package main
import (
"fmt"
"sync"
)
func main() {
var mu sync.Mutex
cond := sync.NewCond(&mu)
ready := false
var wg sync.WaitGroup
var started sync.WaitGroup
woke := 0
for range 4 {
started.Add(1)
wg.Go(func() {
mu.Lock()
started.Done()
for !ready {
cond.Wait()
}
woke++
mu.Unlock()
})
}
started.Wait() // all four are about to wait
mu.Lock()
ready = true
cond.Broadcast() // wakes every waiter
mu.Unlock()
wg.Wait()
fmt.Println("goroutines released:", woke)
}goroutines released: 4One Broadcast releases all four. Four Signal calls would work in this case too, but Broadcast is correct without counting waiters, which is the point.
The mistakes
Waiting without holding the lock. Wait unlocks the mutex, so calling it unlocked panics with sync: unlock of unlocked mutex, a fatal error that cannot be recovered. Always lock first.
if instead of for. An if checks the condition once. After a wake-up the condition may be false again (another goroutine took the item), and the code proceeds on a lie. Use for.
Signalling before the state change. The state must be changed first, under the lock, and then signalled. Signalling first, even with the lock held, works in practice because the waiter needs the lock to proceed, but it reads like a bug; do it in the logical order.
Lost wake-ups from stateless signals. A Cond does not remember a signal. If Signal runs before the waiter calls Wait, the waiter sleeps forever, unless the waiter checked a condition that already became true, which is what the state variable is for. The loop over a condition on shared state is what makes this safe: the waiter looks at the state first, and only waits if the state says to. Using a Cond with no state, as a bare "event", is the mistake.
Forgetting to wake on close or cancel. A Cond has no select and no context support. A goroutine in Wait cannot be cancelled, so a queue that can be closed needs a closed flag checked in the loop and a Broadcast on close. Cancellation by context cannot interrupt Wait at all, which is the strongest argument for channels.
Where it appears in real code
The standard library uses condition variables in a few places you may have read: the HTTP/2 client and its pipes use them to wait on connection and stream state. In application code the typical appearances are connection pools that wait for a free connection, rate limiters that wait for tokens, and work queues with priorities. Each of them also has a channel-based version, which is why the choice is often a matter of taste and of whether cancellation matters. If you need Wait with a timeout or a context, you will end up wrapping the Cond with a helper goroutine and a channel, at which point you have written a worse channel. The lesson is to start with the channel version (channels explained) and move to a Cond only when a measured or structural reason appears, and to put the data and its Cond in one type, so the lock discipline stays in one file (mutexes).
When a channel is the better tool
For a single waiter and a single event, a channel is simpler and lets the waiter also select on a timeout or a context. For a bounded queue, a buffered channel is the built-in version of the example above, with closing and select support included; unless you need something a channel cannot do, such as peeking at the head, resizing, or priority order, use one (buffered channels). Cond earns its place when many goroutines wait on different conditions over the same data structure, when you must wake all waiters at once on a state change that is more than a one-shot event, or when you need the exact semantics of a condition variable ported from another language. In ordinary Go code that is uncommon, and the Go team's own guidance, in the type's documentation, is that most uses of Cond are better replaced by channels.
The reference is the sync.Cond documentation, and the classic treatment of the monitor pattern it implements is worth reading if you want the theory: Monitor (synchronization) on Wikipedia covers condition variables and why the loop is required.


