sync.Cond: an Underrated Gem
Go’s standard library ships with an impressive set of tools for building concurrent applications. Most Go developers reach for channels, sync.Mutex, or sync.WaitGroup — and that covers the vast majority of use cases. But there’s one primitive that is underrated in my opinion: sync.Cond.
What Is sync.Cond?
Imagine you have a group of workers that need to coordinate before proceeding with a task. Your first instinct might be to use channels, and that’s usually the right call. But consider a scenario where goroutines need to sleep until a specific condition is met — say, a connection pool with a maximum of 10 open connections shared by 20 workers. You need workers to sleep when no connections are available and wake up only when one is freed, while also tracking other state variables simultaneously. In cases like these, relying solely on channels, mutexes, or wait groups can get awkward fast.
In 99% of cases, you won’t need
sync.Cond. Channels and goroutines will do the job. But when you need fine-grained control over sleeping and waking goroutines based on a shared condition,sync.Condis the right tool.
The API
sync.Cond requires a sync.Locker (typically a *sync.Mutex) and exposes three methods:
Wait()— suspends the goroutine and releases the associated lock. The scheduler ignores it until it’s woken up.Signal()— wakes the first goroutine waiting in the queue.Broadcast()— wakes all waiting goroutines.
A Practical Example
Let’s build a small job processing system. Workers capitalize strings, with a simulated delay:
1func processWord(s string) string {
2 time.Sleep(time.Duration(rand.Intn(5)) * time.Second)
3 return strings.ToUpper(s)
4}
Shared State
Workers need access to a shared job queue and a couple of control flags:
1var (
2 mu sync.Mutex
3 cond = sync.NewCond(&mu)
4 jobs []string
5 workersStopped = false
6 exit = false
7)
muprotects concurrent access to the shared state.condis our condition variable, tied tomu.workersStoppedpauses all workers when set totrue.exitsignals a clean shutdown.
The Producer
A background goroutine adds a random job to the queue every two seconds and signals a waiting worker:
1func startProducer() {
2 words := []string{"hello", "world", "foo", "bar", "baz", "qux"}
3
4 go func() {
5 for {
6 time.Sleep(2 * time.Second)
7
8 mu.Lock()
9
10 if exit {
11 mu.Unlock()
12 return
13 }
14
15 word := words[rand.Intn(len(words))]
16 jobs = append(jobs, word)
17 log.Printf("[producer] added job: %s", word)
18 mu.Unlock()
19
20 cond.Signal() // wake one worker to handle the new job
21 }
22 }()
23}
The Workers
Each worker loops, sleeping when there’s no work to do or when workers are paused:
1func createWorker(id int) {
2 for {
3 mu.Lock()
4
5 // sleep when: no jobs or paused — but not if we're exiting
6 for (len(jobs) == 0 || workersStopped) && !exit {
7 cond.Wait() // releases the lock while sleeping
8 }
9
10 if exit {
11 log.Printf("worker %d: bye bye", id)
12 mu.Unlock()
13 return
14 }
15
16 if workersStopped {
17 mu.Unlock()
18 continue
19 }
20
21 job := jobs[0]
22 jobs = jobs[1:]
23 mu.Unlock()
24
25 log.Printf("worker %d: %s", id, processWord(job))
26 }
27}
Notice the sleep condition is checked inside a for loop, not an if. This guards against spurious wakeups — situations where a goroutine wakes up even though the condition hasn’t actually been met (for example, because another worker already consumed the job). Wrapping Wait() in a loop is idiomatic Go and prevents subtle bugs. See Wikipedia on spurious wakeups for more.
Wiring It Together
The main function starts the workers and producer, then listens for commands on stdin:
1func startWorkers() {
2 for i := range 5 {
3 go func(id int) {
4 createWorker(id)
5 }(i)
6 }
7}
8
9func main() {
10 startWorkers()
11 startProducer()
12
13 scanner := bufio.NewScanner(os.Stdin)
14 for scanner.Scan() {
15 line := strings.TrimSpace(scanner.Text())
16
17 switch line {
18 case "stop":
19 mu.Lock()
20 workersStopped = true
21 mu.Unlock()
22 log.Println("[main] workers paused")
23
24 case "start":
25 mu.Lock()
26 workersStopped = false
27 cond.Broadcast() // wake all workers — they were paused
28 mu.Unlock()
29 log.Println("[main] workers resumed")
30
31 case "exit":
32 mu.Lock()
33 exit = true
34 cond.Broadcast() // wake all workers so they see exit=true
35 mu.Unlock()
36 log.Println("[main] shutting down")
37 time.Sleep(time.Second) // give workers time to clean up
38 return
39
40 default:
41 // treat any other input as a manual job
42 mu.Lock()
43 jobs = append(jobs, line)
44 mu.Unlock()
45 cond.Signal()
46 }
47 }
48}
Let’s run the code and see what happens:

Why Not Just Use Channels?
Channels handle most concurrency problems elegantly. But there’s one thing they can’t do: re-open after closing.
When you need to broadcast to all goroutines, you can close a channel — every receiver will unblock immediately. But closing is permanent. You can’t close and re-open a channel to implement a pause/resume flow.
sync.Cond has no such limitation. Broadcast() can be called repeatedly, making it well-suited for scenarios where the “wake all” signal needs to fire more than once over the lifetime of the program.
Wrapping Up
sync.Cond is a niche tool, but it fills a gap that channels alone can’t easily cover: coordinating goroutines around a shared, re-evaluable condition. If you find yourself reaching for complex channel gymnastics to implement sleeping workers with multiple control variables, sync.Cond is worth a closer look.
You can find the code snippet for this project at this gist.