17.5 sync.Cond: Conditional Variable for Complex Waiting
Alright, let’s talk about sync.Cond, the most misunderstood and, frankly, most misused part of the sync package. If sync.Mutex is a straightforward bouncer at a club, sync.Cond is the club’s event coordinator who whispers, “The band’s about to go on,” but only to the specific group of people waiting for that exact news. It’s for those situations where a simple mutex and a for-loop just won’t cut it.
The core problem sync.Cond solves is efficient waiting. Imagine you have a goroutine that needs to wait until a certain condition on some shared data becomes true. You could just do:
mu.Lock()
for condition != true {
mu.Unlock()
time.Sleep(10 * time.Millisecond) // Yikes.
mu.Lock()
}
// ... finally do work
mu.Unlock()
This is called busy-waiting, and it’s a horror show. You’re burning CPU cycles, creating timing-dependent chaos, and generally being a bad citizen. sync.Cond gives you a way to put a goroutine to sleep until the condition you care about might have changed, at which point it’s politely awakened to go check. It’s the difference between constantly refreshing your email and having a notification pop up when a new message arrives.
The Anatomy of a Condition Variable
You create one with sync.NewCond, which takes a sync.Locker (usually a Mutex or RWMutex). This intimate link is crucial—the condition variable is always associated with the protection of a specific piece of shared data.
var mu sync.Mutex
cond := sync.NewCond(&mu)
// The shared data this cond protects
queue := make([]interface{}, 0, 10)
The three main methods are:
Wait(): This is the magic. You must call this while holding the associated lock. It atomically unlocks the mutex and suspends the current goroutine. Later, when it’s signaled, it re-acquires the lock before returning. Yes, you need to check the condition again in a loop whenWait()returns. More on that tragic necessity in a second.Signal(): Wakes up one goroutine that’s waiting on the condition (if any). Use this when one event happens that only one goroutine can handle (e.g., one item was added to the queue).Broadcast(): Wakes up all goroutines currently waiting on the condition. Use this when a state change could be relevant to all waiters (e.g., the queue is being shut down, or a massive batch of items was added).
The Mandatory Wait Loop and Spurious Wakeups
Here’s the single most important thing to remember, and the thing everyone gets wrong: You must wait for your condition in a for loop, not an if statement.
// Consumer Goroutine (CORRECT)
mu.Lock()
for len(queue) == 0 {
cond.Wait() // unlocks mu, waits, re-locks mu on return
}
// We now have the lock AND the condition is true
item := queue[0]
queue = queue[1:]
mu.Unlock()
fmt.Println("Processing:", item)
// Consumer Goroutine (DANGEROUSLY WRONG)
mu.Lock()
if len(queue) == 0 {
cond.Wait()
}
// ^^ What if the condition is still false?!
Why the loop? Two reasons. First, spurious wakeups. The Go spec allows Wait() to return even if no Signal() or Broadcast() was called. It’s rare, but your code must be correct even if it happens. The loop re-checks the condition.
Second, and more practically, the condition might still be false even after a legitimate wakeup. If you Broadcast() to ten waiting goroutines, the first one might wake up, grab the one available item, and leave the other nine with nothing. When those nine get scheduled and their Wait() returns, they must check the condition again (len(queue) == 0) to see if they should go back to waiting. An if statement would let them all plunge ahead, assuming an item exists when it doesn’t.
A Realistic Producer-Consumer Example
Let’s wire it all together. Here, a producer adds items, signaling one consumer at a time. The consumers wait until there’s work to do.
package main
import (
"fmt"
"sync"
"time"
)
func main() {
var mu sync.Mutex
cond := sync.NewCond(&mu)
var queue []int
// Producer
go func() {
for i := 0; i < 10; i++ {
time.Sleep(100 * time.Millisecond) // Simulate work
mu.Lock()
queue = append(queue, i)
mu.Unlock()
cond.Signal() // Tell one consumer something is ready
}
}()
// Consumer
go func() {
for {
mu.Lock()
for len(queue) == 0 {
cond.Wait() // Wait until something is in the queue
}
item := queue[0]
queue = queue[1:]
fmt.Printf("Consumed: %d\n", item)
mu.Unlock()
}
}()
time.Sleep(2 * time.Second)
}
When to Use Cond (And When Not To)
sync.Cond is a sharp tool. You need it for complex coordination around state changes in low-level shared data structures. But for 95% of use cases, you’re better off with channels. Waiting for a single event? Use a chan struct{}. A stream of work? Use a chan YourType. Channels are more composable and fit the Go philosophy better.
Use sync.Cond when:
- You’re building a blocking data structure (like a bounded queue).
- The condition is based on complex state that isn’t just a single message.
- You need to wake up multiple goroutines with
Broadcast()(e.g., on shutdown).
The biggest pitfall, besides the wait-loop mistake, is overcomplicating your design. If you find yourself reaching for a Cond, take a breath and ask if a channel or a simpler WaitGroup would do the trick. If the answer is still no, then buckle up, use that for loop, and coordinate with precision.