Hi there! I'm Shrijith Venkatrama, founder of Hexmos. Right now, I’m building LiveAPI, a tool that makes generating API docs from your code ridiculously easy.

In Go concurrency, goroutines often need to wait for work, but doing so efficiently can be a challenge—constant checks waste CPU time, while delays slow things down.

Condition variables, via sync.Cond, offer a solution by minimizing resource use and improving response times.

In this post, we’ll explore how they address this issue and why understanding them can make you a more effective Go engineer.

1. The Problem: Wasting CPU Cycles

Picture a worker goroutine tasked with processing items from a queue. A simple but inefficient solution is to have it constantly check the queue in a loop, burning CPU cycles while waiting for work.

Case 1: Busy-Waiting Worker (Relentless Looping)

package main

import (
    "fmt"
    "time"
)

var queue []int
var iterations int

func worker() {
    for {
        iterations++ // Track each check
        if len(queue) == 0 {
            continue
        }
        break
    }
}

func main() {
    go worker()
    time.Sleep(2 * time.Second) // Let it spin
    fmt.Println("Busy-wait iterations:", iterations)
}

My Machine Reports

Busy-wait iterations: 8,168,421,879

In just 2 seconds, this worker churned through over 8 billion iterations—all for nothing. That’s a staggering amount of CPU time wasted on empty checks.


2. Adding Sleep: Less Waste, Slower Response

To curb the CPU hogging, a common tweak is to pause between checks using time.Sleep.

package main

import (
    "fmt"
    "time"
)

var sleepIterations int

func worker() {
    queue := []int{}
    for {
        sleepIterations++
        if len(queue) == 0 {
            time.Sleep(10 * time.Millisecond)
            continue
        }
        break
    }
}

func main() {
    go worker()
    time.Sleep(2 * time.Second)
    fmt.Println("Sleep-based iterations:", sleepIterations)
}

My Machine Reports

Sleep-based iterations: 195

Now we’re down to ~200 checks instead of 8 billion—a huge improvement. But there’s a catch: the worker still wakes up periodically to check an empty queue, delaying its response when real work arrives.


3. Enter Condition Variables: Smart Waiting

A condition variable offers a better way. It lets the worker sleep efficiently until explicitly signaled, slashing CPU waste and improving responsiveness.

package main

import (
    "sync"
    "fmt"
    "time"
)

var cond = sync.NewCond(&sync.Mutex{})
var condIterations int

func worker() {
    queue := []int{}
    cond.L.Lock()
    for len(queue) == 0 {
        condIterations++
        cond.Wait() // Sleep until signaled
    }
    cond.L.Unlock()
}

func main() {
    go worker()
    time.Sleep(2 * time.Second)
    fmt.Println("Condition variable wake-ups:", condIterations)
}

My Machine Reports...

Condition variable wake-ups: 1

Here, the worker sleeps completely, waking up just once when there’s work to do. No CPU cycles are squandered on pointless checks.


4. Scaling Up: Condition Variables with Multiple Goroutines

Now let’s see condition variables in action with multiple workers sharing a queue.

package main

import (
    "fmt"
    "sync"
    "time"
)

var queue []int
var cond = sync.NewCond(&sync.Mutex{})

func worker(id int) {
    for {
        cond.L.Lock()
        for len(queue) == 0 {
            cond.Wait()
        }
        // Process one item
        if len(queue) > 0 {
            item := queue[0]
            queue = queue[1:]
            fmt.Println("Worker", id, "Processing", item)
            // Signal after unlocking to avoid blocking others
            defer cond.Signal()
        }
        cond.L.Unlock()

        // Brief pause to let other workers run
        time.Sleep(10 * time.Millisecond)
    }
}

func main() {
    for i := 1; i <= 3; i++ {
        go worker(i)
    }
    time.Sleep(1 * time.Second)

    cond.L.Lock()
    queue = append(queue, 42, 43, 44) // Add items
    cond.Broadcast() // Wake all workers
    cond.L.Unlock()

    time.Sleep(3 * time.Second)
}

Sample Output

Worker 1 Processing 42
Worker 2 Processing 43
Worker 3 Processing 44

Go’s sync.Cond enables clean coordination. Workers wait patiently:

cond.L.Lock()  // Protect the queue
for len(queue) == 0 {
    cond.Wait() // Release lock and sleep
}

When cond.Wait() runs, the goroutine:

  • Releases the mutex
  • Suspends itself
  • Reclaims the lock upon waking

A producer adds work and signals:

cond.L.Lock()
queue = append(queue, 42, 43, 44)
cond.Broadcast() // Wake all waiting workers
cond.L.Unlock()

Each worker processes an item and passes the baton:

item := queue[0]
queue = queue[1:]
fmt.Println("Worker", id, "Processing", item)
cond.Signal() // Notify the next worker

This creates a smooth handoff, ensuring work continues as long as items remain.


5. Why Condition Variables Matter

Approach CPU Checks Behavior
Busy-Waiting 5 million+ Relentless polling
Sleep Strategy 200 Periodic delays
Condition Var 1 Wakes only on need

Condition variables shine by eliminating waste and waking workers precisely when there’s something to do. For efficient, responsive waiting in Go, sync.Cond is your go-to tool.

Happy coding!