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.Cond is 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)
  • mu protects concurrent access to the shared state.
  • cond is our condition variable, tied to mu.
  • workersStopped pauses all workers when set to true.
  • exit signals 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:

Running the code

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.