A goroutine sometimes cannot make progress until shared state changes. A worker may need to wait until a queue contains an item. A producer may need to wait until that queue has free capacity. Several goroutines may need to sleep until a service becomes ready.
Polling the state in a loop wastes CPU or forces you to invent arbitrary sleep intervals. Channels solve many coordination problems more directly, but they are not always a natural fit when several goroutines already share state protected by a mutex and need to wait for predicates over that state.
A predicate is simply a condition such as len(queue) > 0 or ready == true.
Go’s sync.Cond is built for this situation. It lets goroutines wait without busy-looping and lets other goroutines announce that the shared state may now satisfy a waiting predicate.
The key mental model is:
sync.Cond does not store an event
shared state stores the truth
Cond only tells waiters: "the state may have changed; check again"That distinction explains most of the API’s rules.
Start with the smallest useful wait loop
Suppose several goroutines must wait until initialization is complete:
var mu sync.Mutex
ready := false
cond := sync.NewCond(&mu)
waitUntilReady := func() {
cond.L.Lock()
for !ready {
cond.Wait()
}
cond.L.Unlock()
}A goroutine that marks the state ready can update the same protected state and wake the waiters:
cond.L.Lock()
ready = true
cond.Broadcast()
cond.L.Unlock()Broadcast wakes every goroutine currently waiting on the condition variable. Each awakened goroutine must reacquire cond.L before its call to Wait returns.
The condition variable and the state are therefore connected by the lock. Code observes or changes ready while holding that same lock.
Understand what Wait actually does
Calling Wait is not equivalent to sleeping while still holding a mutex.
Wait performs three important steps as one synchronization operation:
caller holds cond.L
|
v
Wait unlocks cond.L and suspends the goroutine
|
Signal or Broadcast
|
v
Wait reacquires cond.L before returningThe unlock matters because another goroutine needs access to the protected state in order to make the predicate true.
This code would be wrong:
mu.Lock()
for !ready {
time.Sleep(10 * time.Millisecond)
}
mu.Unlock()The sleeping goroutine keeps the mutex locked. A goroutine that needs the same mutex to set ready = true cannot do so, so the loop can deadlock.
Cond.Wait avoids that problem by releasing the associated lock while the caller is blocked.
Always check the predicate in a loop
A common mistake is to use if:
cond.L.Lock()
if !ready {
cond.Wait()
}
useSharedState()
cond.L.Unlock()That is unsafe because waking up does not reserve the condition for this goroutine.
For example, imagine several consumers waiting because a queue is empty. A producer adds one item and wakes multiple consumers. The first consumer to reacquire the mutex can take the item. By the time another awakened consumer gets the lock, the queue can be empty again.
The correct form rechecks the predicate while holding the lock:
cond.L.Lock()
for !ready {
cond.Wait()
}
useSharedState()
cond.L.Unlock()The standard library documentation explicitly recommends this pattern. Wait returns only after a Signal or Broadcast, but the condition that matters to your program can still be false when the caller reacquires the lock.
Think of a notification as permission to recheck, not permission to proceed.
Signal and Broadcast solve different wake-up problems
Signal wakes one waiting goroutine, if there is one:
cond.Signal()Broadcast wakes all current waiters:
cond.Broadcast()Choose based on what the state transition can enable.
If adding one queue item can let only one consumer proceed, waking one consumer is usually enough. If changing ready from false to true makes every waiter eligible to continue, Broadcast matches the state transition better.
Neither operation hands the mutex directly to a waiter. Awakened goroutines still compete to reacquire the associated lock, and Signal does not imply scheduling priority for the goroutine it wakes.
The API permits calling Signal and Broadcast without holding cond.L. In practice, the shared state that determines the predicate still needs correct synchronization. Keeping the state mutation and notification close together under the same lock often makes the invariant easiest to review. For very large broadcasts, holding the lock while waking many waiters can also extend lock hold time, so low-level code may deliberately structure the notification differently after proving the state transition remains safe.
Build a bounded queue around two predicates
A bounded queue gives sync.Cond a more realistic job. It has two independent reasons for goroutines to wait:
consumer waits while: queue is empty
producer waits while: queue is fullBoth predicates depend on the same items slice, so one mutex protects the queue. Two condition variables make the two kinds of notification explicit.
type Queue struct {
mu sync.Mutex
notEmpty *sync.Cond
notFull *sync.Cond
items []string
capacity int
closed bool
}
func NewQueue(capacity int) *Queue {
if capacity <= 0 {
panic("capacity must be positive")
}
q := &Queue{capacity: capacity}
q.notEmpty = sync.NewCond(&q.mu)
q.notFull = sync.NewCond(&q.mu)
return q
}Both condition variables share q.mu because both predicates describe the same protected state.
Producers wait until capacity is available
A producer waits while the queue is full, unless the queue has been closed:
var ErrClosed = errors.New("queue closed")
func (q *Queue) Put(item string) error {
q.mu.Lock()
defer q.mu.Unlock()
for len(q.items) == q.capacity && !q.closed {
q.notFull.Wait()
}
if q.closed {
return ErrClosed
}
q.items = append(q.items, item)
q.notEmpty.Signal()
return nil
}Adding an item changes len(q.items) == 0 from true to false when the queue was previously empty. At least one consumer may now be able to proceed, so Put signals notEmpty.
The producer checks closed in both places for different reasons. The loop must stop waiting if Close wakes blocked producers, and the later check prevents an awakened producer from adding an item after closure.
Consumers wait until an item exists
The consumer mirrors the same structure:
func (q *Queue) Get() (string, error) {
q.mu.Lock()
defer q.mu.Unlock()
for len(q.items) == 0 && !q.closed {
q.notEmpty.Wait()
}
if len(q.items) == 0 {
return "", ErrClosed
}
item := q.items[0]
q.items = q.items[1:]
q.notFull.Signal()
return item, nil
}Removing an item creates free capacity, so one blocked producer may now proceed. Get therefore signals notFull.
Notice the close behavior: a closed queue can still return items already buffered. It returns ErrClosed only when no item remains. That is an API choice, not behavior supplied by sync.Cond; your own state and predicates define the semantics.
Closing must wake every kind of waiter
Setting closed = true changes the reason both producers and consumers wait. Every blocked operation needs a chance to recheck its loop condition:
func (q *Queue) Close() {
q.mu.Lock()
if !q.closed {
q.closed = true
q.notEmpty.Broadcast()
q.notFull.Broadcast()
}
q.mu.Unlock()
}Using only Signal here could leave other goroutines asleep even though the state has permanently changed and no later operation is guaranteed to wake them.
This is a broader rule: when a transition makes a predicate permanently resolvable for all waiters, Broadcast is often the appropriate notification.
The protected predicate prevents missed state changes
Condition variables are sometimes described as vulnerable to “missed wakeups.” The important defense is not storing notifications. It is checking and changing the predicate under the same mutex that Wait uses.
Consider this sequence:
consumer locks mutex
consumer sees queue empty
consumer calls Wait
Wait atomically releases mutex and blocks
producer acquires mutex
producer appends item
producer signals consumerThere is no gap where the consumer has released the mutex but has not yet entered the wait operation in a way that lets the producer’s state change slip past unnoticed. That transition is exactly what Wait coordinates.
By contrast, checking shared state without the associated lock separates the predicate test from the wait protocol and can introduce races or lost progress.
Notifications also establish synchronization
sync.Cond provides more than a scheduling hint.
The Go memory model specifies that a call to Broadcast or Signal synchronizes before a Wait call that it unblocks. That gives condition-variable notifications a defined memory-ordering relationship.
In normal Cond usage, you should still reason about the shared data through the mutex. The lock makes the state invariant visible in the code and protects simultaneous access. Do not treat the memory-ordering guarantee as a reason to read or write the predicate without its lock.
Cancellation is an important limitation
Cond.Wait has no context.Context parameter, timeout, or built-in cancellation operation.
That matters in request-scoped or shutdown-sensitive code. A goroutine blocked in Wait needs some state transition plus a Signal or Broadcast before it can return and observe that it should stop.
You can model cancellation as part of the protected predicate, as the queue’s closed flag does. However, wiring external context cancellation into such a design often adds another goroutine or more coordination machinery.
If cancellation and select are central requirements, channels are frequently a better abstraction:
select {
case item := <-items:
use(item)
case <-ctx.Done():
return ctx.Err()
}Do not choose sync.Cond merely because it is lower level. Choose it when its state-based waiting model makes the program simpler.
Prefer channels for ownership transfer and one-shot events
Go’s sync package documentation notes that channels are better for many simple cases.
A channel naturally represents a stream of values, ownership transfer, bounded buffering, cancellation through select, or a one-time broadcast by closing a channel.
A condition variable is more compelling when:
- several predicates describe mutable state already protected by a mutex;
- a state transition may make one or many waiters eligible;
- waiters need to inspect shared state after waking rather than receive a value;
- building equivalent channel plumbing would duplicate the state you already maintain.
The bounded queue above is useful for teaching Cond, but a buffered channel is usually simpler if all you need is an ordinary producer-consumer queue:
items := make(chan string, capacity)The custom queue becomes justified only when you need semantics that the channel does not express conveniently, such as several related predicates or specialized lifecycle rules.
Common mistakes come from treating Cond as an event queue
Waiting with if instead of for
A wake-up means the predicate may have changed. Always recheck it under the lock.
Reading the predicate outside the lock
The mutex is part of the protocol. Protect both observations and mutations of the shared state consistently.
Broadcasting for every small change
Waking every waiter can cause many goroutines to contend for the same mutex even when only one can make progress. Prefer Signal when one state change enables one waiter.
Signaling the wrong condition variable
With multiple predicates, notify the waiters whose predicate may have changed. Adding an item affects notEmpty; removing one affects notFull.
Copying a Cond after use
A sync.Cond must not be copied after first use. Store it in a stable structure, commonly as a pointer returned by sync.NewCond.
Use sync.Cond when the state is the real abstraction
sync.Cond is easiest to understand when you stop thinking of Signal and Broadcast as events that carry meaning by themselves.
The meaning lives in shared state. The mutex protects that state. Wait releases the mutex while a goroutine cannot make progress and reacquires it before the goroutine checks again. Signal and Broadcast simply tell waiters that a state transition may have made their predicates true.
That model leads to durable code: define the predicate clearly, guard it with the associated lock, wait in a loop, and wake only the set of goroutines whose predicates may now be satisfied. When a channel expresses the same coordination more directly, prefer the channel. When several goroutines truly need to wait on predicates over shared mutable state, sync.Cond gives that design an explicit synchronization primitive.